@cloud-cli/s3mini 1.42.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/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/auth/sigv4.d.ts +16 -0
- package/dist/auth/sigv4.js +97 -0
- package/dist/auth/sigv4.js.map +1 -0
- package/dist/handlers/router.d.ts +4 -0
- package/dist/handlers/router.js +898 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/replication/worker.d.ts +30 -0
- package/dist/replication/worker.js +122 -0
- package/dist/replication/worker.js.map +1 -0
- package/dist/storage/s3mini.d.ts +157 -0
- package/dist/storage/s3mini.js +960 -0
- package/dist/storage/s3mini.js.map +1 -0
- package/dist/test/aws-sdk.test.d.ts +1 -0
- package/dist/test/aws-sdk.test.js +115 -0
- package/dist/test/aws-sdk.test.js.map +1 -0
- package/dist/test/models.test.d.ts +1 -0
- package/dist/test/models.test.js +27 -0
- package/dist/test/models.test.js.map +1 -0
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +380 -0
- package/dist/test/router.test.js.map +1 -0
- package/dist/test/storage.test.d.ts +1 -0
- package/dist/test/storage.test.js +214 -0
- package/dist/test/storage.test.js.map +1 -0
- package/dist/test/worker.test.d.ts +1 -0
- package/dist/test/worker.test.js +65 -0
- package/dist/test/worker.test.js.map +1 -0
- package/dist/types/aws-s3.d.ts +4 -0
- package/dist/types/aws-s3.js +4 -0
- package/dist/types/aws-s3.js.map +1 -0
- package/dist/types/contracts.d.ts +396 -0
- package/dist/types/contracts.js +4 -0
- package/dist/types/contracts.js.map +1 -0
- package/dist/types/models.d.ts +118 -0
- package/dist/types/models.js +40 -0
- package/dist/types/models.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { S3Error, VALID_STORAGE_CLASSES } from '../types/models.js';
|
|
3
|
+
import { verifyPresignedSigV4, verifySigV4 } from '../auth/sigv4.js';
|
|
4
|
+
export async function registerRoutes(fastify, s3, replication) {
|
|
5
|
+
fastify.addContentTypeParser(['application/octet-stream', 'application/xml', 'text/xml', 'text/csv'], { parseAs: 'buffer' }, (_request, body, done) => {
|
|
6
|
+
done(null, body);
|
|
7
|
+
});
|
|
8
|
+
fastify.addHook('onSend', async (request, reply, payload) => {
|
|
9
|
+
reply.header('x-amz-request-id', request.id);
|
|
10
|
+
reply.header('x-amz-id-2', request.id);
|
|
11
|
+
return payload;
|
|
12
|
+
});
|
|
13
|
+
fastify.addHook('preValidation', async (request) => {
|
|
14
|
+
if (request.url === '/admin' || request.url.startsWith('/admin/'))
|
|
15
|
+
return;
|
|
16
|
+
const authorization = request.headers.authorization;
|
|
17
|
+
const hasPresign = new URL(request.raw.url || '/', 'http://localhost').searchParams.has('X-Amz-Algorithm');
|
|
18
|
+
const credentials = await resolveCredentials(s3, request, hasPresign);
|
|
19
|
+
const accessKeyId = credentials?.accessKeyId;
|
|
20
|
+
const secretAccessKey = credentials?.secretAccessKey;
|
|
21
|
+
const signingCredentials = credentials ? { ...credentials, region: process.env.S3MINI_REGION || 'us-east-1' } : undefined;
|
|
22
|
+
if (hasPresign && (!signingCredentials || !verifyPresignedSigV4({ method: request.method, url: request.raw.url || '/', headers: request.headers, body: Buffer.isBuffer(request.body) ? request.body : undefined }, signingCredentials))) {
|
|
23
|
+
throw new S3Error('SignatureDoesNotMatch', 'The presigned URL signature does not match.', 403);
|
|
24
|
+
}
|
|
25
|
+
if (authorization && (!signingCredentials || !verifySigV4({ method: request.method, url: request.raw.url || '/', headers: request.headers, body: Buffer.isBuffer(request.body) ? request.body : undefined }, signingCredentials))) {
|
|
26
|
+
throw new S3Error('SignatureDoesNotMatch', 'The request signature does not match.', 403);
|
|
27
|
+
}
|
|
28
|
+
if (credentials && (authorization || hasPresign))
|
|
29
|
+
await s3.markAccessKeyUsed(credentials.accessKeyId);
|
|
30
|
+
const params = request.params;
|
|
31
|
+
const query = request.query;
|
|
32
|
+
const key = params['*'] ? normalizeObjectKey(params['*']) : undefined;
|
|
33
|
+
if (params.bucket && query.policy === undefined && query.acl === undefined && (key || request.method !== 'PUT')) {
|
|
34
|
+
const action = key
|
|
35
|
+
? `${request.method === 'GET' || request.method === 'HEAD' ? 'Get' : request.method === 'PUT' ? 'Put' : request.method === 'DELETE' ? 'Delete' : request.method}Object`
|
|
36
|
+
: request.method === 'GET' ? 'ListBucket' : `${request.method}Bucket`;
|
|
37
|
+
const credentialsConfigured = Boolean(process.env.S3MINI_ACCESS_KEY && process.env.S3MINI_SECRET_KEY);
|
|
38
|
+
const authenticatedPrincipal = authorization || hasPresign ? accessKeyId || '' : 'anonymous';
|
|
39
|
+
const context = {
|
|
40
|
+
's3:x-amz-acl': String(request.headers['x-amz-acl'] || ''),
|
|
41
|
+
's3:prefix': query.prefix,
|
|
42
|
+
'aws:PrincipalArn': authenticatedPrincipal,
|
|
43
|
+
};
|
|
44
|
+
if (await s3.isRequestDenied(params.bucket, key, `s3:${action}`, authenticatedPrincipal, context)) {
|
|
45
|
+
throw new S3Error('AccessDenied', 'Access denied by bucket policy.', 403, params.bucket, key);
|
|
46
|
+
}
|
|
47
|
+
if (credentialsConfigured && key && authenticatedPrincipal === 'anonymous' && ['GetObject', 'PutObject', 'DeleteObject'].includes(action) && await s3.isObjectRequestDenied(params.bucket, key, action, authenticatedPrincipal)) {
|
|
48
|
+
throw new S3Error('AccessDenied', 'Access denied by object ACL.', 403, params.bucket, key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
fastify.put('/internal/replication', async (request, reply) => {
|
|
53
|
+
const expectedToken = process.env.S3MINI_REPLICATION_TOKEN;
|
|
54
|
+
const suppliedToken = request.headers['x-s3mini-replication-token'];
|
|
55
|
+
if (!expectedToken || !suppliedToken || !timingSafeTokenEqual(String(suppliedToken), expectedToken))
|
|
56
|
+
throw new S3Error('AccessDenied', 'The replication token is invalid.', 403);
|
|
57
|
+
const bucket = String(request.headers['x-s3mini-bucket'] || '');
|
|
58
|
+
const key = String(request.headers['x-s3mini-key'] || '');
|
|
59
|
+
const versionId = String(request.headers['x-s3mini-version-id'] || '');
|
|
60
|
+
const operation = String(request.headers['x-s3mini-operation'] || '');
|
|
61
|
+
const deleteMarker = request.headers['x-s3mini-delete-marker'] === 'true';
|
|
62
|
+
const etag = String(request.headers['x-s3mini-etag'] || '');
|
|
63
|
+
const sha256 = request.headers['x-s3mini-sha256'] ? String(request.headers['x-s3mini-sha256']) : undefined;
|
|
64
|
+
const lastModified = Number(request.headers['x-s3mini-last-modified']);
|
|
65
|
+
if (!bucket || !key || !operation || (operation === 'PutObject' && !versionId) || !Number.isFinite(lastModified))
|
|
66
|
+
throw new S3Error('InvalidRequest', 'Replication metadata is incomplete.', 400);
|
|
67
|
+
if (operation === 'PutObject') {
|
|
68
|
+
if (!etag || !Buffer.isBuffer(request.body))
|
|
69
|
+
throw new S3Error('InvalidRequest', 'Replicated object data is missing.', 400);
|
|
70
|
+
await s3.acceptReplicatedObject({ bucket, key, versionId, etag, sha256, lastModified, body: request.body });
|
|
71
|
+
}
|
|
72
|
+
else if (operation === 'DeleteObject') {
|
|
73
|
+
await s3.acceptReplicatedDelete({ bucket, key, versionId, lastModified, deleteMarker });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
throw new S3Error('InvalidRequest', 'The replication operation is unsupported.', 400);
|
|
77
|
+
}
|
|
78
|
+
return reply.code(204).send();
|
|
79
|
+
});
|
|
80
|
+
fastify.get('/internal/replication/inventory', async (request, reply) => {
|
|
81
|
+
const expectedToken = process.env.S3MINI_REPLICATION_TOKEN;
|
|
82
|
+
const suppliedToken = request.headers['x-s3mini-replication-token'];
|
|
83
|
+
if (!expectedToken || !suppliedToken || !timingSafeTokenEqual(String(suppliedToken), expectedToken))
|
|
84
|
+
throw new S3Error('AccessDenied', 'The replication token is invalid.', 403);
|
|
85
|
+
return reply.send(await s3.listReplicationInventory());
|
|
86
|
+
});
|
|
87
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
88
|
+
if (error instanceof S3Error) {
|
|
89
|
+
reply.type('application/xml').code(error.httpCode).send(error.toResponseXml(request.id));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
fastify.log.error(error);
|
|
93
|
+
reply.type('application/xml').code(500).send(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>InternalError</Code><Message>${escapeXml(error.message)}</Message><RequestId>${escapeXml(request.id)}</RequestId></Error>`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
async function requireAdmin(request) {
|
|
97
|
+
const configuredToken = process.env.S3MINI_ADMIN_TOKEN;
|
|
98
|
+
const suppliedToken = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : undefined;
|
|
99
|
+
if (configuredToken && suppliedToken && timingSafeTokenEqual(suppliedToken, configuredToken))
|
|
100
|
+
return;
|
|
101
|
+
const oidcToken = getCookie(request, 's3mini_oidc_token');
|
|
102
|
+
if (oidcToken && await isOidcAdmin(oidcToken))
|
|
103
|
+
return;
|
|
104
|
+
throw new S3Error('AccessDenied', 'The admin token is invalid.', 403);
|
|
105
|
+
}
|
|
106
|
+
function oidcConfigured() {
|
|
107
|
+
return Boolean(process.env.S3MINI_OIDC_CLIENT_ID && process.env.S3MINI_OIDC_CLIENT_SECRET);
|
|
108
|
+
}
|
|
109
|
+
function oidcBaseUrl() {
|
|
110
|
+
return (process.env.S3MINI_OIDC_AUTH_URL || 'https://auth.api.apphor.de').replace(/\/$/, '');
|
|
111
|
+
}
|
|
112
|
+
function requestBaseUrl(request) {
|
|
113
|
+
const protocol = String(request.headers['x-forwarded-proto'] || 'http').split(',')[0];
|
|
114
|
+
return `${protocol}://${request.headers.host || 'localhost'}`;
|
|
115
|
+
}
|
|
116
|
+
function getCookie(request, name) {
|
|
117
|
+
const value = String(request.headers.cookie || '').split(';').map(item => item.trim()).find(item => item.startsWith(`${name}=`));
|
|
118
|
+
return value ? decodeURIComponent(value.slice(name.length + 1)) : undefined;
|
|
119
|
+
}
|
|
120
|
+
async function isOidcAdmin(token) {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(`${oidcBaseUrl()}/userinfo`, { headers: { authorization: `Bearer ${token}`, 'x-auth-audience': process.env.S3MINI_OIDC_AUDIENCE || process.env.S3MINI_OIDC_CLIENT_ID } });
|
|
123
|
+
if (!response.ok)
|
|
124
|
+
return false;
|
|
125
|
+
const user = await response.json();
|
|
126
|
+
const allowed = (process.env.S3MINI_OIDC_ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean);
|
|
127
|
+
return !allowed.length || (!!user.email && allowed.includes(user.email));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
fastify.get('/admin', async (request, reply) => {
|
|
134
|
+
if (oidcConfigured() && !getCookie(request, 's3mini_oidc_token'))
|
|
135
|
+
return reply.redirect('/admin/login');
|
|
136
|
+
return reply.type('text/html').send(ADMIN_HTML);
|
|
137
|
+
});
|
|
138
|
+
fastify.get('/admin/login', async (request, reply) => {
|
|
139
|
+
if (!oidcConfigured())
|
|
140
|
+
throw new S3Error('AccessDenied', 'OIDC is not configured.', 403);
|
|
141
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
142
|
+
const state = crypto.randomBytes(24).toString('base64url');
|
|
143
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
144
|
+
const redirectUri = process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/admin/callback`;
|
|
145
|
+
const stateCookie = Buffer.from(JSON.stringify({ state, verifier }), 'utf8').toString('base64url');
|
|
146
|
+
reply.header('Set-Cookie', `s3mini_oidc_state=${stateCookie}; HttpOnly; Path=/admin; SameSite=Lax; Max-Age=600`);
|
|
147
|
+
const url = new URL(`${oidcBaseUrl()}/authorize`);
|
|
148
|
+
url.search = new URLSearchParams({ response_type: 'code', client_id: process.env.S3MINI_OIDC_CLIENT_ID, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
|
149
|
+
return reply.redirect(url.toString());
|
|
150
|
+
});
|
|
151
|
+
fastify.get('/admin/callback', async (request, reply) => {
|
|
152
|
+
if (!oidcConfigured())
|
|
153
|
+
throw new S3Error('AccessDenied', 'OIDC is not configured.', 403);
|
|
154
|
+
const query = request.query;
|
|
155
|
+
const saved = getCookie(request, 's3mini_oidc_state');
|
|
156
|
+
if (!saved || !query.code || !query.state)
|
|
157
|
+
throw new S3Error('AccessDenied', query.error || 'The OIDC callback is invalid.', 403);
|
|
158
|
+
let state;
|
|
159
|
+
try {
|
|
160
|
+
state = JSON.parse(Buffer.from(saved, 'base64url').toString('utf8'));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new S3Error('AccessDenied', 'The OIDC state is invalid.', 403);
|
|
164
|
+
}
|
|
165
|
+
if (state.state !== query.state)
|
|
166
|
+
throw new S3Error('AccessDenied', 'The OIDC state does not match.', 403);
|
|
167
|
+
const redirectUri = process.env.S3MINI_OIDC_REDIRECT_URI || `${requestBaseUrl(request)}/admin/callback`;
|
|
168
|
+
const tokenResponse = await fetch(`${oidcBaseUrl()}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: query.code, client_id: process.env.S3MINI_OIDC_CLIENT_ID, client_secret: process.env.S3MINI_OIDC_CLIENT_SECRET, redirect_uri: redirectUri, code_verifier: state.verifier }) });
|
|
169
|
+
if (!tokenResponse.ok)
|
|
170
|
+
throw new S3Error('AccessDenied', 'The OIDC token exchange failed.', 403);
|
|
171
|
+
const token = (await tokenResponse.json()).access_token;
|
|
172
|
+
if (!token)
|
|
173
|
+
throw new S3Error('AccessDenied', 'The OIDC token response was incomplete.', 403);
|
|
174
|
+
reply.header('Set-Cookie', `s3mini_oidc_token=${encodeURIComponent(token)}; HttpOnly; Path=/admin; SameSite=Lax; Max-Age=3600`);
|
|
175
|
+
return reply.redirect('/admin');
|
|
176
|
+
});
|
|
177
|
+
fastify.get('/admin/replication/health', { preHandler: requireAdmin }, async (_request, reply) => reply.send(replication?.getPeerHealth() || []));
|
|
178
|
+
fastify.get('/admin/replication/events', { preHandler: requireAdmin }, async (request, reply) => {
|
|
179
|
+
const query = request.query;
|
|
180
|
+
const statuses = ['Pending', 'Delivered', 'Failed', 'DeadLetter'];
|
|
181
|
+
const status = statuses.includes(query.status) ? query.status : undefined;
|
|
182
|
+
return reply.send(await s3.listReplicationEvents(Number(query.limit) || 100, status));
|
|
183
|
+
});
|
|
184
|
+
fastify.post('/admin/replication/events/:id/retry', { preHandler: requireAdmin }, async (request, reply) => {
|
|
185
|
+
await s3.retryReplicationEvent(Number(request.params.id));
|
|
186
|
+
return reply.code(204).send();
|
|
187
|
+
});
|
|
188
|
+
fastify.get('/admin/access-keys', { preHandler: requireAdmin }, async (_request, reply) => {
|
|
189
|
+
return reply.send(await s3.listAccessKeys());
|
|
190
|
+
});
|
|
191
|
+
fastify.post('/admin/access-keys', { preHandler: requireAdmin }, async (request, reply) => {
|
|
192
|
+
const body = (request.body && typeof request.body === 'object') ? request.body : {};
|
|
193
|
+
return reply.code(201).send(await s3.createAccessKey(body.displayName || ''));
|
|
194
|
+
});
|
|
195
|
+
fastify.delete('/admin/access-keys/:accessKeyId', { preHandler: requireAdmin }, async (request, reply) => {
|
|
196
|
+
const { accessKeyId } = request.params;
|
|
197
|
+
await s3.setAccessKeyStatus(accessKeyId, 'Disabled');
|
|
198
|
+
return reply.code(204).send();
|
|
199
|
+
});
|
|
200
|
+
fastify.get('/admin/buckets', { preHandler: requireAdmin }, async (_request, reply) => reply.send(await s3.listBuckets()));
|
|
201
|
+
fastify.post('/admin/buckets', { preHandler: requireAdmin }, async (request, reply) => {
|
|
202
|
+
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
|
203
|
+
if (!body.name)
|
|
204
|
+
throw new S3Error('InvalidBucketName', 'A bucket name is required.', 400);
|
|
205
|
+
await s3.createBucket(body.name, body.locationConstraint);
|
|
206
|
+
return reply.code(201).send({ name: body.name });
|
|
207
|
+
});
|
|
208
|
+
fastify.delete('/admin/buckets/:bucket', { preHandler: requireAdmin }, async (request, reply) => {
|
|
209
|
+
await s3.deleteBucket(request.params.bucket);
|
|
210
|
+
return reply.code(204).send();
|
|
211
|
+
});
|
|
212
|
+
fastify.get('/admin/buckets/:bucket/policy', { preHandler: requireAdmin }, async (request, reply) => {
|
|
213
|
+
const bucket = request.params.bucket;
|
|
214
|
+
return reply.send((await s3.getBucketConfiguration(bucket, 'policy')) || {});
|
|
215
|
+
});
|
|
216
|
+
fastify.put('/admin/buckets/:bucket/policy', { preHandler: requireAdmin }, async (request, reply) => {
|
|
217
|
+
const bucket = request.params.bucket;
|
|
218
|
+
if (!request.body || typeof request.body !== 'object')
|
|
219
|
+
throw new S3Error('MalformedPolicy', 'A JSON bucket policy is required.', 400, bucket);
|
|
220
|
+
await s3.putBucketConfiguration(bucket, 'policy', request.body);
|
|
221
|
+
return reply.code(204).send();
|
|
222
|
+
});
|
|
223
|
+
fastify.delete('/admin/buckets/:bucket/policy', { preHandler: requireAdmin }, async (request, reply) => {
|
|
224
|
+
await s3.deleteBucketConfiguration(request.params.bucket, 'policy');
|
|
225
|
+
return reply.code(204).send();
|
|
226
|
+
});
|
|
227
|
+
// --- Bucket Operations ---
|
|
228
|
+
async function listBuckets(request, reply) {
|
|
229
|
+
const buckets = await s3.listBuckets();
|
|
230
|
+
const response = {
|
|
231
|
+
Owner: { ID: '000000000000000000000000', DisplayName: 's3mini' },
|
|
232
|
+
Buckets: buckets.map(b => ({ Name: b.name, CreationDate: b.creationDate.toISOString() }))
|
|
233
|
+
};
|
|
234
|
+
reply.type('application/xml').send(wrapXml('ListAllMyBucketsResult', response));
|
|
235
|
+
}
|
|
236
|
+
async function putBucket(request, reply) {
|
|
237
|
+
const params = request.params;
|
|
238
|
+
const query = request.query;
|
|
239
|
+
if (query.versioning !== undefined) {
|
|
240
|
+
const body = String(request.body || '');
|
|
241
|
+
const status = readXmlTag(body, 'Status');
|
|
242
|
+
if (!status)
|
|
243
|
+
throw new S3Error('MalformedXML', 'Versioning status is required.', 400, params.bucket);
|
|
244
|
+
await s3.putVersioning(params.bucket, status);
|
|
245
|
+
return reply.code(200).send();
|
|
246
|
+
}
|
|
247
|
+
if (query.tagging !== undefined) {
|
|
248
|
+
const tags = parseTagXml(String(request.body || ''));
|
|
249
|
+
await s3.putBucketTags(params.bucket, tags);
|
|
250
|
+
return reply.code(200).send();
|
|
251
|
+
}
|
|
252
|
+
const configuration = configurationQuery(query);
|
|
253
|
+
if (configuration) {
|
|
254
|
+
const value = configuration === 'lifecycleConfiguration' ? parseLifecycleXml(request.body) : parseJsonOrXml(request.body);
|
|
255
|
+
await s3.putBucketConfiguration(params.bucket, configuration, value);
|
|
256
|
+
return reply.code(200).send();
|
|
257
|
+
}
|
|
258
|
+
await s3.createBucket(params.bucket, query.locationConstraint);
|
|
259
|
+
reply.code(200).send();
|
|
260
|
+
}
|
|
261
|
+
async function headBucket(request, reply) {
|
|
262
|
+
const params = request.params;
|
|
263
|
+
await s3.headBucket(params.bucket);
|
|
264
|
+
reply.code(200).send();
|
|
265
|
+
}
|
|
266
|
+
async function deleteBucket(request, reply) {
|
|
267
|
+
const params = request.params;
|
|
268
|
+
const query = request.query;
|
|
269
|
+
if (query.tagging !== undefined) {
|
|
270
|
+
await s3.deleteBucketTags(params.bucket);
|
|
271
|
+
return reply.code(204).send();
|
|
272
|
+
}
|
|
273
|
+
const configuration = configurationQuery(query);
|
|
274
|
+
if (configuration) {
|
|
275
|
+
await s3.deleteBucketConfiguration(params.bucket, configuration);
|
|
276
|
+
return reply.code(204).send();
|
|
277
|
+
}
|
|
278
|
+
await s3.deleteBucket(params.bucket);
|
|
279
|
+
reply.code(204).send();
|
|
280
|
+
}
|
|
281
|
+
fastify.get('/', { exposeHeadRoute: false }, listBuckets);
|
|
282
|
+
fastify.put('/:bucket', putBucket);
|
|
283
|
+
fastify.head('/:bucket', headBucket);
|
|
284
|
+
fastify.delete('/:bucket', deleteBucket);
|
|
285
|
+
fastify.post('/:bucket', async (request, reply) => {
|
|
286
|
+
const params = request.params;
|
|
287
|
+
const query = request.query;
|
|
288
|
+
if (query.delete === undefined)
|
|
289
|
+
throw new S3Error('InvalidRequest', 'The delete query parameter is required.', 400, params.bucket);
|
|
290
|
+
const keys = [...String(request.body || '').matchAll(/<Object\b[^>]*>\s*<Key>([^<]*)<\/Key>/g)].map(match => unescapeXml(match[1]));
|
|
291
|
+
if (!keys.length)
|
|
292
|
+
throw new S3Error('MalformedXML', 'At least one object key is required.', 400, params.bucket);
|
|
293
|
+
const result = await s3.deleteObjects(params.bucket, keys);
|
|
294
|
+
return reply.type('application/xml').send(wrapXml('DeleteResult', {
|
|
295
|
+
Deleted: result.deleted.map(Key => ({ Key })),
|
|
296
|
+
Errors: result.errors.map(error => ({ Key: error.key, Code: error.code })),
|
|
297
|
+
}));
|
|
298
|
+
});
|
|
299
|
+
fastify.options('/:bucket/*', async (request, reply) => {
|
|
300
|
+
const params = request.params;
|
|
301
|
+
const origin = request.headers.origin;
|
|
302
|
+
const requestedMethod = request.headers['access-control-request-method'];
|
|
303
|
+
const requestedHeaders = request.headers['access-control-request-headers'];
|
|
304
|
+
const configuration = await s3.getBucketConfiguration(params.bucket, 'corsConfiguration');
|
|
305
|
+
const rule = configuration?.rules?.find(candidate => {
|
|
306
|
+
const origins = Array.isArray(candidate.allowedOrigins) ? candidate.allowedOrigins : [];
|
|
307
|
+
const methods = Array.isArray(candidate.allowedMethods) ? candidate.allowedMethods : [];
|
|
308
|
+
return !!origin && (origins.includes('*') || origins.includes(origin)) && (!requestedMethod || methods.includes(String(requestedMethod)));
|
|
309
|
+
});
|
|
310
|
+
if (!rule)
|
|
311
|
+
throw new S3Error('AccessDenied', 'CORS request is not allowed.', 403, params.bucket);
|
|
312
|
+
const origins = Array.isArray(rule.allowedOrigins) ? rule.allowedOrigins : [];
|
|
313
|
+
const methods = Array.isArray(rule.allowedMethods) ? rule.allowedMethods : [];
|
|
314
|
+
const allowedHeaders = Array.isArray(rule.allowedHeaders) ? rule.allowedHeaders : [];
|
|
315
|
+
return reply.code(204)
|
|
316
|
+
.header('Access-Control-Allow-Origin', origins.includes('*') ? '*' : origin)
|
|
317
|
+
.header('Access-Control-Allow-Methods', methods.join(','))
|
|
318
|
+
.header('Access-Control-Allow-Headers', requestedHeaders || allowedHeaders.join(','))
|
|
319
|
+
.header('Access-Control-Max-Age', String(rule.maxAgeSeconds || 0))
|
|
320
|
+
.send();
|
|
321
|
+
});
|
|
322
|
+
// --- Object Operations ---
|
|
323
|
+
async function putObject(request, reply) {
|
|
324
|
+
const params = request.params;
|
|
325
|
+
const key = normalizeObjectKey(request.params['*']);
|
|
326
|
+
const query = request.query;
|
|
327
|
+
if (!key) {
|
|
328
|
+
if (query.versioning !== undefined || query.tagging !== undefined || configurationQuery(query))
|
|
329
|
+
return putBucket(request, reply);
|
|
330
|
+
await s3.createBucket(params.bucket, query.locationConstraint);
|
|
331
|
+
return reply.code(200).send();
|
|
332
|
+
}
|
|
333
|
+
if (query.uploadId && query.partNumber) {
|
|
334
|
+
const copySource = request.headers['x-amz-copy-source'];
|
|
335
|
+
if (copySource) {
|
|
336
|
+
const source = decodeURIComponent(String(copySource)).replace(/^\//, '').split('/');
|
|
337
|
+
const sourceBucket = source.shift();
|
|
338
|
+
if (!sourceBucket || !source.length)
|
|
339
|
+
throw new S3Error('InvalidRequest', 'x-amz-copy-source is invalid.', 400, params.bucket, key);
|
|
340
|
+
const part = await s3.uploadPartCopy(params.bucket, key, query.uploadId, Number(query.partNumber), sourceBucket, source.join('/'));
|
|
341
|
+
return reply.type('application/xml').code(200).send(wrapXml('CopyPartResult', { ETag: part.etag, LastModified: part.lastModified.toISOString() }));
|
|
342
|
+
}
|
|
343
|
+
const part = await s3.uploadPart({ bucket: params.bucket, key, uploadId: query.uploadId, partNumber: Number(query.partNumber), body: request.body });
|
|
344
|
+
return reply.code(200).header('ETag', part.etag).send();
|
|
345
|
+
}
|
|
346
|
+
if (query.tagging !== undefined) {
|
|
347
|
+
await s3.putObjectTags(params.bucket, key, parseTagXml(String(request.body || '')), query.versionId);
|
|
348
|
+
return reply.code(200).send();
|
|
349
|
+
}
|
|
350
|
+
if (query.acl !== undefined) {
|
|
351
|
+
const canned = request.headers['x-amz-acl'] || readXmlTag(String(request.body || ''), 'CannedACL') || 'private';
|
|
352
|
+
const body = String(request.body || '');
|
|
353
|
+
const acl = body.includes('<Grant>') ? parseAclXml(body) : { CannedACL: canned };
|
|
354
|
+
await s3.putObjectAcl(params.bucket, key, acl, query.versionId);
|
|
355
|
+
return reply.code(200).send();
|
|
356
|
+
}
|
|
357
|
+
const copySource = request.headers['x-amz-copy-source'];
|
|
358
|
+
if (copySource) {
|
|
359
|
+
const source = decodeURIComponent(String(copySource)).replace(/^\//, '').split('/');
|
|
360
|
+
const sourceBucket = source.shift();
|
|
361
|
+
if (!sourceBucket || !source.length)
|
|
362
|
+
throw new S3Error('InvalidRequest', 'x-amz-copy-source is invalid.', 400);
|
|
363
|
+
const obj = await s3.copyObject(sourceBucket, source.join('/'), params.bucket, key);
|
|
364
|
+
return reply.type('application/xml').code(200).send(wrapXml('CopyObjectResult', { ETag: obj.etag, LastModified: obj.lastModified.toISOString() }));
|
|
365
|
+
}
|
|
366
|
+
const body = request.body;
|
|
367
|
+
const checksum = request.headers['x-amz-checksum-sha256'];
|
|
368
|
+
const contentSha256 = request.headers['x-amz-content-sha256'];
|
|
369
|
+
const contentSha256Digest = crypto.createHash('sha256').update(body).digest('hex');
|
|
370
|
+
if (contentSha256 && contentSha256 !== 'UNSIGNED-PAYLOAD' && String(contentSha256) !== contentSha256Digest) {
|
|
371
|
+
throw new S3Error('BadDigest', 'The x-amz-content-sha256 checksum did not match the request body.', 400, params.bucket, key);
|
|
372
|
+
}
|
|
373
|
+
const b2Sha1 = request.headers['x-bz-content-sha1'];
|
|
374
|
+
const b2Sha1Digest = crypto.createHash('sha1').update(body).digest('hex');
|
|
375
|
+
if (b2Sha1 && b2Sha1 !== 'do_not_verify' && String(b2Sha1) !== b2Sha1Digest) {
|
|
376
|
+
throw new S3Error('BadDigest', 'The x-bz-content-sha1 checksum did not match the request body.', 400, params.bucket, key);
|
|
377
|
+
}
|
|
378
|
+
const contentMd5 = request.headers['content-md5'];
|
|
379
|
+
const contentMd5Digest = crypto.createHash('md5').update(body).digest('base64');
|
|
380
|
+
if (contentMd5 && String(contentMd5) !== contentMd5Digest) {
|
|
381
|
+
throw new S3Error('BadDigest', 'The Content-MD5 checksum did not match the request body.', 400, params.bucket, key);
|
|
382
|
+
}
|
|
383
|
+
const checksumSha256 = crypto.createHash('sha256').update(body).digest('base64');
|
|
384
|
+
if (checksum && checksum !== checksumSha256) {
|
|
385
|
+
throw new S3Error('BadDigest', 'The SHA-256 checksum did not match the request body.', 400, params.bucket, key);
|
|
386
|
+
}
|
|
387
|
+
const meta = {};
|
|
388
|
+
const storageClass = request.headers['x-amz-storage-class'];
|
|
389
|
+
if (storageClass && !VALID_STORAGE_CLASSES.includes(String(storageClass))) {
|
|
390
|
+
throw new S3Error('InvalidStorageClass', 'The storage class is not supported.', 400, params.bucket, key);
|
|
391
|
+
}
|
|
392
|
+
if (storageClass)
|
|
393
|
+
meta.storageClass = storageClass;
|
|
394
|
+
if (request.headers['content-type'])
|
|
395
|
+
meta.contentType = request.headers['content-type'];
|
|
396
|
+
if (request.headers['content-language'])
|
|
397
|
+
meta.contentLanguage = request.headers['content-language'];
|
|
398
|
+
if (request.headers['content-disposition'])
|
|
399
|
+
meta.contentDisposition = request.headers['content-disposition'];
|
|
400
|
+
if (request.headers['content-encoding'])
|
|
401
|
+
meta.contentEncoding = request.headers['content-encoding'];
|
|
402
|
+
if (request.headers['cache-control'])
|
|
403
|
+
meta.cacheControl = request.headers['cache-control'];
|
|
404
|
+
if (request.headers['expires'])
|
|
405
|
+
meta.expires = new Date(request.headers['expires']);
|
|
406
|
+
meta.userMetadata = Object.fromEntries(Object.entries(request.headers)
|
|
407
|
+
.filter(([name]) => name.toLowerCase().startsWith('x-amz-meta-'))
|
|
408
|
+
.map(([name, value]) => [name.slice('x-amz-meta-'.length).toLowerCase(), Array.isArray(value) ? value.join(',') : String(value)]));
|
|
409
|
+
const encryption = request.headers['x-amz-server-side-encryption'];
|
|
410
|
+
const kmsKeyId = request.headers['x-amz-server-side-encryption-aws-kms-key-id'];
|
|
411
|
+
if (encryption && encryption !== 'AES256' && encryption !== 'aws:kms') {
|
|
412
|
+
throw new S3Error('InvalidEncryptionAlgorithmError', 'The requested encryption algorithm is not supported.', 400, params.bucket, key);
|
|
413
|
+
}
|
|
414
|
+
if (encryption === 'aws:kms' && !kmsKeyId) {
|
|
415
|
+
throw new S3Error('InvalidRequest', 'A KMS key identifier is required for aws:kms encryption.', 400, params.bucket, key);
|
|
416
|
+
}
|
|
417
|
+
if (encryption)
|
|
418
|
+
meta.serverSideEncryption = encryption;
|
|
419
|
+
if (kmsKeyId)
|
|
420
|
+
meta.sseKmsKeyId = kmsKeyId;
|
|
421
|
+
const lockMode = request.headers['x-amz-object-lock-mode'];
|
|
422
|
+
const retainUntil = request.headers['x-amz-object-lock-retain-until-date'];
|
|
423
|
+
const legalHold = request.headers['x-amz-object-lock-legal-hold'];
|
|
424
|
+
if (lockMode && lockMode !== 'GOVERNANCE' && lockMode !== 'COMPLIANCE')
|
|
425
|
+
throw new S3Error('InvalidRequest', 'Unsupported object lock mode.', 400, params.bucket, key);
|
|
426
|
+
if (retainUntil && Number.isNaN(Date.parse(String(retainUntil))))
|
|
427
|
+
throw new S3Error('InvalidRequest', 'Invalid retention date.', 400, params.bucket, key);
|
|
428
|
+
if (legalHold && legalHold !== 'ON' && legalHold !== 'OFF')
|
|
429
|
+
throw new S3Error('InvalidRequest', 'Invalid legal hold status.', 400, params.bucket, key);
|
|
430
|
+
if (lockMode)
|
|
431
|
+
meta.objectLockMode = lockMode;
|
|
432
|
+
if (retainUntil)
|
|
433
|
+
meta.retainUntil = new Date(String(retainUntil));
|
|
434
|
+
if (legalHold)
|
|
435
|
+
meta.legalHold = legalHold;
|
|
436
|
+
const obj = await s3.putObject(params.bucket, key, body, meta);
|
|
437
|
+
reply.type('application/xml').code(200).header('x-amz-checksum-sha256', checksumSha256).send(wrapXml('PutObjectResult', { ETag: obj.etag, ChecksumSHA256: checksumSha256 }));
|
|
438
|
+
}
|
|
439
|
+
async function getObject(request, reply) {
|
|
440
|
+
const params = request.params;
|
|
441
|
+
const key = normalizeObjectKey(request.params['*']);
|
|
442
|
+
const query = request.query;
|
|
443
|
+
if (!key)
|
|
444
|
+
return listObjectsV2(request, reply);
|
|
445
|
+
if (query.uploadId && !query.tagging) {
|
|
446
|
+
const parts = await s3.listParts({ bucket: params.bucket, key, uploadId: query.uploadId, partNumberMarker: query['part-number-marker'] ? Number(query['part-number-marker']) : undefined });
|
|
447
|
+
return reply.type('application/xml').send(wrapXml('ListPartsResult', {
|
|
448
|
+
Bucket: parts.bucket,
|
|
449
|
+
Key: parts.key,
|
|
450
|
+
UploadId: parts.uploadId,
|
|
451
|
+
IsTruncated: parts.isTruncated,
|
|
452
|
+
Parts: parts.parts.map(part => ({ PartNumber: part.partNumber, ETag: part.etag, Size: part.size, LastModified: part.lastModified.toISOString() })),
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
if (query.tagging !== undefined) {
|
|
456
|
+
const tags = await s3.getObjectTags(params.bucket, key, query.versionId);
|
|
457
|
+
return reply.type('application/xml').send(wrapXml('Tagging', { TagSet: { Tag: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) } }));
|
|
458
|
+
}
|
|
459
|
+
if (query.acl !== undefined) {
|
|
460
|
+
const acl = await s3.getObjectAcl(params.bucket, key, query.versionId);
|
|
461
|
+
return reply.type('application/xml').send(wrapXml('AccessControlPolicy', acl));
|
|
462
|
+
}
|
|
463
|
+
const original = await s3.getObject(params.bucket, key, query.versionId);
|
|
464
|
+
const ifMatch = request.headers['if-match'];
|
|
465
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
466
|
+
if (ifMatch && ifMatch !== '*' && !String(ifMatch).split(',').map(value => value.trim()).includes(original.metadata.etag)) {
|
|
467
|
+
throw new S3Error('PreconditionFailed', 'At least one of the preconditions you specified did not hold.', 412, params.bucket, key);
|
|
468
|
+
}
|
|
469
|
+
if (ifNoneMatch && (ifNoneMatch === '*' || String(ifNoneMatch).split(',').map(value => value.trim()).includes(original.metadata.etag))) {
|
|
470
|
+
return reply.code(304).header('ETag', original.metadata.etag).send();
|
|
471
|
+
}
|
|
472
|
+
let data = original.data;
|
|
473
|
+
let metadata = original.metadata;
|
|
474
|
+
let checksumSha256 = crypto.createHash('sha256').update(original.data).digest('base64');
|
|
475
|
+
let status = 200;
|
|
476
|
+
const range = request.headers.range;
|
|
477
|
+
let contentRange;
|
|
478
|
+
if (range) {
|
|
479
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(String(range));
|
|
480
|
+
if (!match)
|
|
481
|
+
throw new S3Error('InvalidRange', 'The requested range is not satisfiable.', 416, params.bucket, key);
|
|
482
|
+
const start = Number(match[1]);
|
|
483
|
+
const end = match[2] ? Number(match[2]) : undefined;
|
|
484
|
+
const ranged = await s3.getObjectRange(params.bucket, key, start, end);
|
|
485
|
+
data = ranged.data;
|
|
486
|
+
contentRange = `bytes ${start}-${start + data.length - 1}/${ranged.totalSize}`;
|
|
487
|
+
checksumSha256 = crypto.createHash('sha256').update(data).digest('base64');
|
|
488
|
+
status = 206;
|
|
489
|
+
}
|
|
490
|
+
reply.type(metadata.contentType || 'application/octet-stream')
|
|
491
|
+
.header('ETag', metadata.etag)
|
|
492
|
+
.header('Last-Modified', metadata.lastModified.toUTCString())
|
|
493
|
+
.header('Content-Length', String(data.length))
|
|
494
|
+
.header('Content-Language', metadata.contentLanguage || '')
|
|
495
|
+
.header('Cache-Control', metadata.cacheControl || '')
|
|
496
|
+
.header('Content-Disposition', metadata.contentDisposition || '')
|
|
497
|
+
.header('Content-Encoding', metadata.contentEncoding || '')
|
|
498
|
+
.header('x-amz-checksum-sha256', checksumSha256)
|
|
499
|
+
.header('Accept-Ranges', 'bytes')
|
|
500
|
+
.code(status);
|
|
501
|
+
if (metadata.serverSideEncryption)
|
|
502
|
+
reply.header('x-amz-server-side-encryption', metadata.serverSideEncryption);
|
|
503
|
+
if (metadata.sseKmsKeyId)
|
|
504
|
+
reply.header('x-amz-server-side-encryption-aws-kms-key-id', metadata.sseKmsKeyId);
|
|
505
|
+
if (metadata.objectLockMode)
|
|
506
|
+
reply.header('x-amz-object-lock-mode', metadata.objectLockMode);
|
|
507
|
+
if (metadata.objectLockRetainUntilDate)
|
|
508
|
+
reply.header('x-amz-object-lock-retain-until-date', metadata.objectLockRetainUntilDate.toUTCString());
|
|
509
|
+
if (metadata.objectLockLegalHold !== undefined)
|
|
510
|
+
reply.header('x-amz-object-lock-legal-hold', metadata.objectLockLegalHold ? 'ON' : 'OFF');
|
|
511
|
+
for (const [name, value] of Object.entries(metadata.userMetadata))
|
|
512
|
+
reply.header(`x-amz-meta-${name}`, value);
|
|
513
|
+
if (contentRange)
|
|
514
|
+
reply.header('Content-Range', contentRange);
|
|
515
|
+
reply.send(data);
|
|
516
|
+
}
|
|
517
|
+
async function headObject(request, reply) {
|
|
518
|
+
const params = request.params;
|
|
519
|
+
const key = normalizeObjectKey(request.params['*']);
|
|
520
|
+
if (!key)
|
|
521
|
+
return headBucket(request, reply);
|
|
522
|
+
const query = request.query;
|
|
523
|
+
const { metadata } = await s3.getObject(params.bucket, key, query.versionId);
|
|
524
|
+
reply.code(200)
|
|
525
|
+
.header('ETag', metadata.etag)
|
|
526
|
+
.header('Last-Modified', metadata.lastModified.toUTCString())
|
|
527
|
+
.header('Content-Type', metadata.contentType)
|
|
528
|
+
.header('Content-Language', metadata.contentLanguage || '')
|
|
529
|
+
.header('Content-Length', String(metadata.size))
|
|
530
|
+
.header('Cache-Control', metadata.cacheControl || '')
|
|
531
|
+
.header('Content-Disposition', metadata.contentDisposition || '')
|
|
532
|
+
.header('Content-Encoding', metadata.contentEncoding || '')
|
|
533
|
+
.header('x-amz-checksum-sha256', crypto.createHash('sha256').update((await s3.getObject(params.bucket, key)).data).digest('base64'));
|
|
534
|
+
if (metadata.serverSideEncryption)
|
|
535
|
+
reply.header('x-amz-server-side-encryption', metadata.serverSideEncryption);
|
|
536
|
+
if (metadata.sseKmsKeyId)
|
|
537
|
+
reply.header('x-amz-server-side-encryption-aws-kms-key-id', metadata.sseKmsKeyId);
|
|
538
|
+
if (metadata.objectLockMode)
|
|
539
|
+
reply.header('x-amz-object-lock-mode', metadata.objectLockMode);
|
|
540
|
+
if (metadata.objectLockRetainUntilDate)
|
|
541
|
+
reply.header('x-amz-object-lock-retain-until-date', metadata.objectLockRetainUntilDate.toUTCString());
|
|
542
|
+
if (metadata.objectLockLegalHold !== undefined)
|
|
543
|
+
reply.header('x-amz-object-lock-legal-hold', metadata.objectLockLegalHold ? 'ON' : 'OFF');
|
|
544
|
+
for (const [name, value] of Object.entries(metadata.userMetadata))
|
|
545
|
+
reply.header(`x-amz-meta-${name}`, value);
|
|
546
|
+
reply.send();
|
|
547
|
+
}
|
|
548
|
+
async function deleteObject(request, reply) {
|
|
549
|
+
const params = request.params;
|
|
550
|
+
const key = normalizeObjectKey(request.params['*']);
|
|
551
|
+
const query = request.query;
|
|
552
|
+
if (!key)
|
|
553
|
+
return deleteBucket(request, reply);
|
|
554
|
+
if (query.uploadId) {
|
|
555
|
+
await s3.abortMultipartUpload(params.bucket, key, query.uploadId);
|
|
556
|
+
return reply.code(204).send();
|
|
557
|
+
}
|
|
558
|
+
if (query.tagging !== undefined) {
|
|
559
|
+
await s3.deleteObjectTags(params.bucket, key, query.versionId);
|
|
560
|
+
return reply.code(204).send();
|
|
561
|
+
}
|
|
562
|
+
if (query.versionId) {
|
|
563
|
+
await s3.deleteObjectVersion(params.bucket, key, query.versionId);
|
|
564
|
+
return reply.code(204).send();
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
await s3.deleteObject(params.bucket, key);
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (!(error instanceof S3Error) || error.code !== 'NoSuchKey')
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
reply.code(204).send();
|
|
574
|
+
}
|
|
575
|
+
async function listObjectsV2(request, reply) {
|
|
576
|
+
const params = request.params;
|
|
577
|
+
const query = request.query;
|
|
578
|
+
if (query.versions !== undefined) {
|
|
579
|
+
const versions = await s3.listObjectVersions(params.bucket, query.prefix);
|
|
580
|
+
return reply.type('application/xml').send(wrapXml('ListVersionsResult', {
|
|
581
|
+
Name: params.bucket,
|
|
582
|
+
Versions: versions.map(version => ({
|
|
583
|
+
Key: version.Key,
|
|
584
|
+
VersionId: version.VersionId,
|
|
585
|
+
IsLatest: version.IsLatest,
|
|
586
|
+
IsDeleteMarker: version.IsDeleteMarker,
|
|
587
|
+
LastModified: version.LastModified.toISOString(),
|
|
588
|
+
ETag: version.ETag,
|
|
589
|
+
Size: version.Size,
|
|
590
|
+
StorageClass: version.StorageClass,
|
|
591
|
+
})),
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
if (query.uploads !== undefined) {
|
|
595
|
+
const result = await s3.listMultipartUploads({ bucket: params.bucket, prefix: query.prefix, maxUploads: query['max-uploads'] ? Number(query['max-uploads']) : undefined });
|
|
596
|
+
return reply.type('application/xml').send(wrapXml('ListMultipartUploadsResult', {
|
|
597
|
+
Bucket: result.bucket,
|
|
598
|
+
Uploads: result.uploads.map(upload => ({ Key: upload.key, UploadId: upload.uploadId, Initiated: upload.initiated.toISOString(), StorageClass: upload.storageClass })),
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
if (query.location !== undefined) {
|
|
602
|
+
const location = await s3.getBucketLocation(params.bucket);
|
|
603
|
+
return reply.type('application/xml').send(wrapXml('LocationConstraint', location));
|
|
604
|
+
}
|
|
605
|
+
if (query.versioning !== undefined) {
|
|
606
|
+
const versioning = await s3.getVersioning(params.bucket);
|
|
607
|
+
return reply.type('application/xml').send(wrapXml('VersioningConfiguration', versioning.status ? { Status: versioning.status } : {}));
|
|
608
|
+
}
|
|
609
|
+
if (query.tagging !== undefined) {
|
|
610
|
+
const tags = await s3.getBucketTags(params.bucket);
|
|
611
|
+
return reply.type('application/xml').send(wrapXml('Tagging', { TagSet: { Tag: Object.entries(tags).map(([Key, Value]) => ({ Key, Value })) } }));
|
|
612
|
+
}
|
|
613
|
+
const configuration = configurationQuery(query);
|
|
614
|
+
if (configuration) {
|
|
615
|
+
const value = await s3.getBucketConfiguration(params.bucket, configuration);
|
|
616
|
+
return reply.type('application/xml').send(wrapXml(configuration, value || {}));
|
|
617
|
+
}
|
|
618
|
+
const result = await s3.listObjectsV2Advanced({
|
|
619
|
+
bucket: params.bucket,
|
|
620
|
+
prefix: query.prefix,
|
|
621
|
+
delimiter: query.delimiter,
|
|
622
|
+
maxKeys: query['max-keys'] ? Number(query['max-keys']) : undefined,
|
|
623
|
+
continuationToken: query['continuation-token'],
|
|
624
|
+
startAfter: query['start-after'],
|
|
625
|
+
encodingType: query['encoding-type'] === 'url' ? 'url' : undefined,
|
|
626
|
+
});
|
|
627
|
+
const encodeListValue = (value) => result.encodingType === 'url' ? encodeURIComponent(value) : value;
|
|
628
|
+
const response = {
|
|
629
|
+
Name: params.bucket,
|
|
630
|
+
Prefix: result.prefix,
|
|
631
|
+
Delimiter: result.delimiter,
|
|
632
|
+
MaxKeys: result.maxKeys,
|
|
633
|
+
KeyCount: result.keyCount,
|
|
634
|
+
IsTruncated: result.isTruncated,
|
|
635
|
+
NextContinuationToken: result.nextContinuationToken,
|
|
636
|
+
Contents: result.contents.map(c => ({
|
|
637
|
+
Key: encodeListValue(c.key), LastModified: c.lastModified.toISOString(), ETag: c.etag,
|
|
638
|
+
Size: c.size, StorageClass: c.storageClass
|
|
639
|
+
})),
|
|
640
|
+
CommonPrefixes: result.commonPrefixes.map(Prefix => ({ Prefix: encodeListValue(Prefix) })),
|
|
641
|
+
EncodingType: result.encodingType,
|
|
642
|
+
};
|
|
643
|
+
reply.type('application/xml').send(wrapXml('ListObjectsV2Result', response));
|
|
644
|
+
}
|
|
645
|
+
fastify.put('/:bucket/*', putObject);
|
|
646
|
+
fastify.post('/:bucket/*', async (request, reply) => {
|
|
647
|
+
const params = request.params;
|
|
648
|
+
const key = normalizeObjectKey(request.params['*']);
|
|
649
|
+
const query = request.query;
|
|
650
|
+
if (!key && query.delete !== undefined) {
|
|
651
|
+
const keys = [...String(request.body || '').matchAll(/<Object\b[^>]*>\s*<Key>([^<]*)<\/Key>/g)].map(match => unescapeXml(match[1]));
|
|
652
|
+
if (!keys.length)
|
|
653
|
+
throw new S3Error('MalformedXML', 'At least one object key is required.', 400, params.bucket);
|
|
654
|
+
const result = await s3.deleteObjects(params.bucket, keys);
|
|
655
|
+
return reply.type('application/xml').send(wrapXml('DeleteResult', { Deleted: result.deleted.map(Key => ({ Key })), Errors: result.errors.map(error => ({ Key: error.key, Code: error.code })) }));
|
|
656
|
+
}
|
|
657
|
+
if (query.restore !== undefined) {
|
|
658
|
+
await s3.restoreObject(params.bucket, key);
|
|
659
|
+
return reply.code(202).header('x-amz-restore', 'ongoing-request="false"').send();
|
|
660
|
+
}
|
|
661
|
+
if (query.select !== undefined) {
|
|
662
|
+
const expression = readXmlTag(String(request.body || ''), 'Expression') || 'SELECT * FROM S3Object';
|
|
663
|
+
const body = await s3.selectObjectContent(params.bucket, key, expression);
|
|
664
|
+
return reply.type('application/octet-stream').send(body);
|
|
665
|
+
}
|
|
666
|
+
if (query.uploads !== undefined) {
|
|
667
|
+
const result = await s3.createMultipartUpload(params.bucket, key);
|
|
668
|
+
return reply.type('application/xml').send(wrapXml('InitiateMultipartUploadResult', { Bucket: result.bucket, Key: result.key, UploadId: result.uploadId }));
|
|
669
|
+
}
|
|
670
|
+
if (!query.uploadId)
|
|
671
|
+
throw new S3Error('InvalidRequest', 'uploadId is required.', 400, params.bucket, key);
|
|
672
|
+
const parts = [...String(request.body || '').matchAll(/<Part\b[^>]*>([\s\S]*?)<\/Part>/g)]
|
|
673
|
+
.map(match => ({ partNumber: Number(readXmlTag(match[1], 'PartNumber')), etag: unescapeXml(readXmlTag(match[1], 'ETag') || '') }))
|
|
674
|
+
.filter(part => Number.isInteger(part.partNumber) && part.partNumber > 0 && part.etag);
|
|
675
|
+
const result = await s3.completeMultipartUpload({ bucket: params.bucket, key, uploadId: query.uploadId, parts });
|
|
676
|
+
reply.type('application/xml').send(wrapXml('CompleteMultipartUploadResult', { Bucket: result.bucket, Key: result.key, ETag: result.etag }));
|
|
677
|
+
});
|
|
678
|
+
fastify.get('/:bucket/*', { exposeHeadRoute: false }, getObject);
|
|
679
|
+
fastify.head('/:bucket/*', headObject);
|
|
680
|
+
fastify.delete('/:bucket/*', deleteObject);
|
|
681
|
+
fastify.get('/:bucket', { exposeHeadRoute: false }, listObjectsV2);
|
|
682
|
+
}
|
|
683
|
+
function toXml(obj) {
|
|
684
|
+
if (typeof obj !== 'object' || obj === null)
|
|
685
|
+
return escapeXml(String(obj));
|
|
686
|
+
return Object.entries(obj).filter(([, val]) => val !== undefined && val !== null).map(([key, val]) => {
|
|
687
|
+
if (Array.isArray(val)) {
|
|
688
|
+
return val.map(item => `<${key}>${toXml(item)}</${key}>`).join('');
|
|
689
|
+
}
|
|
690
|
+
if (typeof val === 'object') {
|
|
691
|
+
return `<${key}>${toXml(val)}</${key}>`;
|
|
692
|
+
}
|
|
693
|
+
return '<' + key + '>' + escapeXml(String(val)) + '</' + key + '>';
|
|
694
|
+
}).join('\n');
|
|
695
|
+
}
|
|
696
|
+
function escapeXml(value) {
|
|
697
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
698
|
+
}
|
|
699
|
+
function unescapeXml(value) {
|
|
700
|
+
return value.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
701
|
+
}
|
|
702
|
+
function normalizeObjectKey(key) {
|
|
703
|
+
return key.replace(/^\/+/, '');
|
|
704
|
+
}
|
|
705
|
+
function wrapXml(root, content) {
|
|
706
|
+
const body = toXml(content);
|
|
707
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n<' + root + '>\n' + body + '\n</' + root + '>';
|
|
708
|
+
}
|
|
709
|
+
function readXmlTag(body, tag) {
|
|
710
|
+
return body.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
|
|
711
|
+
}
|
|
712
|
+
function parseTagXml(body) {
|
|
713
|
+
const tags = {};
|
|
714
|
+
for (const match of body.matchAll(/<Tag\b[^>]*>\s*<Key>([^<]*)<\/Key>\s*<Value>([^<]*)<\/Value>\s*<\/Tag>/g))
|
|
715
|
+
tags[match[1]] = match[2];
|
|
716
|
+
return tags;
|
|
717
|
+
}
|
|
718
|
+
function parseAclXml(body) {
|
|
719
|
+
const grants = [...body.matchAll(/<Grant>\s*<Grantee(?:\s+[^>]*)?>([\s\S]*?)<\/Grantee>\s*<Permission>([^<]+)<\/Permission>\s*<\/Grant>/g)]
|
|
720
|
+
.map(match => {
|
|
721
|
+
const granteeBody = match[1];
|
|
722
|
+
const grantee = {};
|
|
723
|
+
for (const name of ['Type', 'ID', 'URI', 'DisplayName']) {
|
|
724
|
+
const value = readXmlTag(granteeBody, name);
|
|
725
|
+
if (value)
|
|
726
|
+
grantee[name] = unescapeXml(value);
|
|
727
|
+
}
|
|
728
|
+
return { Grantee: grantee, Permission: unescapeXml(match[2]) };
|
|
729
|
+
});
|
|
730
|
+
return { Owner: { ID: '000000000000000000000000', DisplayName: 's3mini' }, Grants: grants };
|
|
731
|
+
}
|
|
732
|
+
function parseJsonOrXml(body) {
|
|
733
|
+
if (typeof body === 'object' && body !== null && !Buffer.isBuffer(body))
|
|
734
|
+
return body;
|
|
735
|
+
const text = String(body || '');
|
|
736
|
+
try {
|
|
737
|
+
return JSON.parse(text);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
return { raw: text };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function parseLifecycleXml(body) {
|
|
744
|
+
const text = String(body || '');
|
|
745
|
+
const rules = [...text.matchAll(/<Rule\b[^>]*>([\s\S]*?)<\/Rule>/g)].map(match => {
|
|
746
|
+
const ruleBody = match[1];
|
|
747
|
+
const rule = {
|
|
748
|
+
id: readXmlTag(ruleBody, 'ID'),
|
|
749
|
+
status: readXmlTag(ruleBody, 'Status'),
|
|
750
|
+
filter: { prefix: readXmlTag(ruleBody, 'Prefix') || '' },
|
|
751
|
+
};
|
|
752
|
+
const expiration = {};
|
|
753
|
+
const expirationBody = ruleBody.match(/<Expiration(?:Configuration)?\b[^>]*>([\s\S]*?)<\/(?:Expiration|ExpirationConfiguration)>/)?.[1] || ruleBody;
|
|
754
|
+
const days = readXmlTag(expirationBody, 'Days');
|
|
755
|
+
const date = readXmlTag(expirationBody, 'Date');
|
|
756
|
+
if (days)
|
|
757
|
+
expiration.days = days;
|
|
758
|
+
if (date)
|
|
759
|
+
expiration.date = date;
|
|
760
|
+
if (Object.keys(expiration).length)
|
|
761
|
+
rule.expiration = expiration;
|
|
762
|
+
rule.transitions = [...ruleBody.matchAll(/<Transition\b[^>]*>([\s\S]*?)<\/Transition>/g)].map(item => ({
|
|
763
|
+
days: readXmlTag(item[1], 'Days'),
|
|
764
|
+
date: readXmlTag(item[1], 'Date'),
|
|
765
|
+
storageClass: readXmlTag(item[1], 'StorageClass'),
|
|
766
|
+
}));
|
|
767
|
+
rule.noncurrentVersionTransitions = [...ruleBody.matchAll(/<NoncurrentVersionTransition\b[^>]*>([\s\S]*?)<\/NoncurrentVersionTransition>/g)].map(item => ({
|
|
768
|
+
noncurrentDays: readXmlTag(item[1], 'NoncurrentDays'),
|
|
769
|
+
storageClass: readXmlTag(item[1], 'StorageClass'),
|
|
770
|
+
}));
|
|
771
|
+
const noncurrentExpirationBody = ruleBody.match(/<NoncurrentVersionExpiration\b[^>]*>([\s\S]*?)<\/NoncurrentVersionExpiration>/)?.[1];
|
|
772
|
+
const noncurrentDays = noncurrentExpirationBody ? readXmlTag(noncurrentExpirationBody, 'NoncurrentDays') : undefined;
|
|
773
|
+
if (noncurrentDays)
|
|
774
|
+
rule.noncurrentVersionExpiration = { noncurrentDays };
|
|
775
|
+
return rule;
|
|
776
|
+
});
|
|
777
|
+
return { rules };
|
|
778
|
+
}
|
|
779
|
+
function configurationQuery(query) {
|
|
780
|
+
const map = {
|
|
781
|
+
cors: 'corsConfiguration',
|
|
782
|
+
lifecycle: 'lifecycleConfiguration',
|
|
783
|
+
policy: 'policy',
|
|
784
|
+
encryption: 'encryptionConfiguration',
|
|
785
|
+
website: 'websiteConfiguration',
|
|
786
|
+
logging: 'loggingStatus',
|
|
787
|
+
notification: 'notificationConfiguration',
|
|
788
|
+
replication: 'replicationConfiguration',
|
|
789
|
+
acl: 'acl',
|
|
790
|
+
};
|
|
791
|
+
const key = Object.keys(map).find(name => query[name] !== undefined);
|
|
792
|
+
return key ? map[key] : undefined;
|
|
793
|
+
}
|
|
794
|
+
async function resolveCredentials(s3, request, hasPresign) {
|
|
795
|
+
const authorization = request.headers.authorization;
|
|
796
|
+
if (!authorization && !hasPresign)
|
|
797
|
+
return undefined;
|
|
798
|
+
const url = new URL(request.raw.url || '/', 'http://localhost');
|
|
799
|
+
const credentialValue = hasPresign
|
|
800
|
+
? url.searchParams.get('X-Amz-Credential') || undefined
|
|
801
|
+
: authorization?.match(/Credential=([^,\s]+)/)?.[1];
|
|
802
|
+
const accessKeyId = credentialValue ? decodeURIComponent(credentialValue).split('/')[0] : process.env.S3MINI_ACCESS_KEY;
|
|
803
|
+
if (!accessKeyId)
|
|
804
|
+
return undefined;
|
|
805
|
+
if (accessKeyId === process.env.S3MINI_ACCESS_KEY && process.env.S3MINI_SECRET_KEY) {
|
|
806
|
+
return { accessKeyId, secretAccessKey: process.env.S3MINI_SECRET_KEY };
|
|
807
|
+
}
|
|
808
|
+
const stored = await s3.getAccessKey(accessKeyId);
|
|
809
|
+
return stored?.status === 'Active' ? stored : undefined;
|
|
810
|
+
}
|
|
811
|
+
function timingSafeTokenEqual(left, right) {
|
|
812
|
+
const leftBytes = Buffer.from(left);
|
|
813
|
+
const rightBytes = Buffer.from(right);
|
|
814
|
+
return leftBytes.length === rightBytes.length && crypto.timingSafeEqual(leftBytes, rightBytes);
|
|
815
|
+
}
|
|
816
|
+
const ADMIN_HTML = `<!doctype html>
|
|
817
|
+
<html lang="en">
|
|
818
|
+
<head>
|
|
819
|
+
<meta charset="utf-8">
|
|
820
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
821
|
+
<title>S3MINI Control Plane</title>
|
|
822
|
+
<script type="importmap">{"imports":{"@li3/":"https://cdn.li3.dev/@li3/"}}</script>
|
|
823
|
+
<script type="module">import '@li3/web';</script>
|
|
824
|
+
<template component="dashboard-status">
|
|
825
|
+
<p id="message">{{ message }}</p>
|
|
826
|
+
<script setup>
|
|
827
|
+
import { defineProp } from '@li3/web';
|
|
828
|
+
export default function () { const message = defineProp('message', { default: '' }); return { message }; }
|
|
829
|
+
</script>
|
|
830
|
+
</template>
|
|
831
|
+
<style>
|
|
832
|
+
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101418; color: #e6edf3; }
|
|
833
|
+
body { max-width: 900px; margin: 0 auto; padding: 32px 20px; }
|
|
834
|
+
h1 { margin: 0 0 8px; letter-spacing: -0.03em; }
|
|
835
|
+
p { color: #9daab8; }
|
|
836
|
+
section { border: 1px solid #2b3540; border-radius: 10px; padding: 18px; margin-top: 18px; background: #171d23; }
|
|
837
|
+
input, button { border: 1px solid #3b4855; border-radius: 6px; padding: 9px 11px; background: #0e1318; color: inherit; }
|
|
838
|
+
input { min-width: 260px; }
|
|
839
|
+
button { cursor: pointer; background: #245b82; border-color: #347cac; }
|
|
840
|
+
button.danger { background: #743b42; border-color: #a95760; }
|
|
841
|
+
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
842
|
+
th, td { text-align: left; padding: 10px 6px; border-bottom: 1px solid #2b3540; }
|
|
843
|
+
code { overflow-wrap: anywhere; }
|
|
844
|
+
.healthy { color: #8bd49c; }
|
|
845
|
+
.unhealthy, .dead { color: #f28b8b; }
|
|
846
|
+
.unknown { color: #f0c674; }
|
|
847
|
+
#message { min-height: 1.5em; color: #f0c674; }
|
|
848
|
+
</style>
|
|
849
|
+
</head>
|
|
850
|
+
<body>
|
|
851
|
+
<h1>S3MINI Control Plane</h1>
|
|
852
|
+
<p>Manage access keys for this S3MINI instance. Secrets are shown only when a key is issued.</p>
|
|
853
|
+
<section>
|
|
854
|
+
<label>Admin token <input id="token" type="password" autocomplete="off"></label>
|
|
855
|
+
<button id="load">Load keys</button>
|
|
856
|
+
<dashboard-status id="status" message=""></dashboard-status>
|
|
857
|
+
</section>
|
|
858
|
+
<section>
|
|
859
|
+
<form id="create"><input id="name" placeholder="Display name" maxlength="120"><button>Issue access key</button></form>
|
|
860
|
+
<pre id="issued"></pre>
|
|
861
|
+
<table><thead><tr><th>Access key</th><th>Name</th><th>Status</th><th>Created</th><th></th></tr></thead><tbody id="keys"></tbody></table>
|
|
862
|
+
</section>
|
|
863
|
+
<section>
|
|
864
|
+
<h2>Replication</h2>
|
|
865
|
+
<table><thead><tr><th>Peer</th><th>Status</th><th>Failures</th><th>Last activity</th></tr></thead><tbody id="peers"></tbody></table>
|
|
866
|
+
<table><thead><tr><th>Object</th><th>Operation</th><th>Status</th><th>Attempts</th><th></th></tr></thead><tbody id="events"></tbody></table>
|
|
867
|
+
</section>
|
|
868
|
+
<section>
|
|
869
|
+
<h2>Buckets</h2>
|
|
870
|
+
<form id="bucket-create"><input id="bucket-name" placeholder="Bucket name" maxlength="63"><input id="bucket-region" placeholder="Location (optional)"><button>Create bucket</button></form>
|
|
871
|
+
<table><thead><tr><th>Name</th><th>Created</th><th>Policy JSON</th><th></th></tr></thead><tbody id="buckets"></tbody></table>
|
|
872
|
+
</section>
|
|
873
|
+
<script>
|
|
874
|
+
const token = () => document.querySelector('#token').value;
|
|
875
|
+
const message = text => { const status = document.querySelector('#status'); if (status) status.message = text || ''; };
|
|
876
|
+
const html = value => String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
|
877
|
+
const request = (url, options = {}) => fetch(url, { ...options, headers: { ...(options.body ? {'Content-Type': 'application/json'} : {}), Authorization: 'Bearer ' + token(), ...(options.headers || {}) } });
|
|
878
|
+
async function load() {
|
|
879
|
+
const response = await request('/admin/access-keys');
|
|
880
|
+
if (!response.ok) return message('Unable to load keys (' + response.status + ').');
|
|
881
|
+
const keys = await response.json();
|
|
882
|
+
document.querySelector('#keys').innerHTML = keys.map(key => '<tr><td><code>' + html(key.accessKeyId) + '</code></td><td>' + html(key.displayName) + '</td><td>' + html(key.status) + '</td><td>' + html(new Date(key.createdAt).toLocaleString()) + '</td><td>' + (key.status === 'Active' ? '<button class="danger" data-id="' + html(key.accessKeyId) + '">Disable</button>' : '') + '</td></tr>').join('');
|
|
883
|
+
document.querySelectorAll('[data-id]').forEach(button => button.onclick = async () => { await request('/admin/access-keys/' + encodeURIComponent(button.dataset.id), { method: 'DELETE' }); load(); });
|
|
884
|
+
const healthResponse = await request('/admin/replication/health');
|
|
885
|
+
if (healthResponse.ok) { const peers = await healthResponse.json(); document.querySelector('#peers').innerHTML = peers.map(peer => '<tr><td><code>' + html(peer.peer) + '</code></td><td class="' + html(peer.status.toLowerCase()) + '">' + html(peer.status) + '</td><td>' + html(peer.consecutiveFailures) + '</td><td>' + html(new Date(peer.lastSuccessAt || peer.lastFailureAt || 0).toLocaleString()) + '</td></tr>').join('') || '<tr><td colspan="4">No configured peers.</td></tr>'; }
|
|
886
|
+
const eventsResponse = await request('/admin/replication/events?limit=100');
|
|
887
|
+
if (eventsResponse.ok) { const events = await eventsResponse.json(); document.querySelector('#events').innerHTML = events.map(event => '<tr><td><code>' + html(event.bucket + '/' + event.key) + '</code></td><td>' + html(event.operation) + '</td><td class="' + (event.status === 'DeadLetter' ? 'dead' : '') + '">' + html(event.status) + '</td><td>' + html(event.attempts) + '</td><td>' + (event.status === 'DeadLetter' ? '<button data-retry="' + html(event.id) + '">Retry</button>' : '') + '</td></tr>').join('') || '<tr><td colspan="5">No replication events.</td></tr>'; document.querySelectorAll('[data-retry]').forEach(button => button.onclick = async () => { await request('/admin/replication/events/' + button.dataset.retry + '/retry', { method: 'POST' }); load(); }); }
|
|
888
|
+
const bucketsResponse = await request('/admin/buckets');
|
|
889
|
+
if (bucketsResponse.ok) { const buckets = await bucketsResponse.json(); document.querySelector('#buckets').innerHTML = buckets.map(bucket => '<tr><td><code>' + html(bucket.name) + '</code></td><td>' + html(new Date(bucket.creationDate).toLocaleString()) + '</td><td><textarea data-policy="' + html(bucket.name) + '" rows="3" cols="34" placeholder="No policy"></textarea><br><button data-save-policy="' + html(bucket.name) + '">Save</button> <button class="danger" data-clear-policy="' + html(bucket.name) + '">Clear</button></td><td><button class="danger" data-delete-bucket="' + html(bucket.name) + '">Delete</button></td></tr>').join('') || '<tr><td colspan="4">No buckets.</td></tr>'; for (const bucket of buckets) { const response = await request('/admin/buckets/' + encodeURIComponent(bucket.name) + '/policy'); if (response.ok) document.querySelector('[data-policy="' + CSS.escape(bucket.name) + '"]').value = JSON.stringify(await response.json(), null, 2); } document.querySelectorAll('[data-save-policy]').forEach(button => button.onclick = async () => { const name = button.dataset.savePolicy; try { await request('/admin/buckets/' + encodeURIComponent(name) + '/policy', { method: 'PUT', body: document.querySelector('[data-policy="' + CSS.escape(name) + '"]').value }); load(); } catch { message('Unable to save policy.'); } }); document.querySelectorAll('[data-clear-policy]').forEach(button => button.onclick = async () => { await request('/admin/buckets/' + encodeURIComponent(button.dataset.clearPolicy) + '/policy', { method: 'DELETE' }); load(); }); document.querySelectorAll('[data-delete-bucket]').forEach(button => button.onclick = async () => { await request('/admin/buckets/' + encodeURIComponent(button.dataset.deleteBucket), { method: 'DELETE' }); load(); }); }
|
|
890
|
+
message('');
|
|
891
|
+
}
|
|
892
|
+
document.querySelector('#load').onclick = load;
|
|
893
|
+
document.querySelector('#bucket-create').onsubmit = async event => { event.preventDefault(); const name = document.querySelector('#bucket-name').value; const locationConstraint = document.querySelector('#bucket-region').value; const response = await request('/admin/buckets', { method: 'POST', body: JSON.stringify({ name, locationConstraint: locationConstraint || undefined }) }); if (!response.ok) return message('Unable to create bucket (' + response.status + ').'); document.querySelector('#bucket-name').value = ''; load(); };
|
|
894
|
+
document.querySelector('#create').onsubmit = async event => { event.preventDefault(); const response = await request('/admin/access-keys', { method: 'POST', body: JSON.stringify({ displayName: document.querySelector('#name').value }) }); if (!response.ok) return message('Unable to issue key (' + response.status + ').'); const issued = await response.json(); document.querySelector('#issued').textContent = 'Access key: ' + issued.accessKeyId + '\\nSecret: ' + issued.secretAccessKey; document.querySelector('#name').value = ''; load(); };
|
|
895
|
+
</script>
|
|
896
|
+
</body>
|
|
897
|
+
</html>`;
|
|
898
|
+
//# sourceMappingURL=router.js.map
|