@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,960 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { S3Error } from '../types/models.js';
|
|
6
|
+
import { VALID_LOCATION_CONSTRAINTS } from '../types/models.js';
|
|
7
|
+
export const STORAGE_BASE = '/data/objects';
|
|
8
|
+
export const DATABASE_PATH = '/data/s3mini.sqlite';
|
|
9
|
+
export const NODE_ID = process.env.S3MINI_NODE_ID || 'node-local';
|
|
10
|
+
export const REPLICATION_MAX_ATTEMPTS = 8;
|
|
11
|
+
export class S3Mini {
|
|
12
|
+
db;
|
|
13
|
+
initialized = false;
|
|
14
|
+
constructor() { }
|
|
15
|
+
versionPath(bucket, key, versionId) {
|
|
16
|
+
return path.join(STORAGE_BASE, bucket, '.versions', versionId, key);
|
|
17
|
+
}
|
|
18
|
+
validateKey(key) {
|
|
19
|
+
if (!key || path.isAbsolute(key) || key.split('/').some(part => part === '..')) {
|
|
20
|
+
throw new S3Error('InvalidObjectName', 'The object key is invalid.', 400, undefined, key);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async init() {
|
|
24
|
+
if (this.initialized)
|
|
25
|
+
return;
|
|
26
|
+
await fs.mkdir(STORAGE_BASE, { recursive: true });
|
|
27
|
+
try {
|
|
28
|
+
await fs.access(DATABASE_PATH);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
try {
|
|
32
|
+
await fs.copyFile(path.join(STORAGE_BASE, 'meta.db'), DATABASE_PATH);
|
|
33
|
+
}
|
|
34
|
+
catch { /* Start with a new database. */ }
|
|
35
|
+
}
|
|
36
|
+
this.db = new DatabaseSync(DATABASE_PATH);
|
|
37
|
+
this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL');
|
|
38
|
+
await this.run(`CREATE TABLE IF NOT EXISTS buckets (
|
|
39
|
+
name TEXT PRIMARY KEY,
|
|
40
|
+
locationConstraint TEXT DEFAULT 'us-east-1',
|
|
41
|
+
creationDate INTEGER
|
|
42
|
+
)`);
|
|
43
|
+
await this.run(`CREATE TABLE IF NOT EXISTS objs (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
bucket TEXT NOT NULL,
|
|
46
|
+
key TEXT NOT NULL,
|
|
47
|
+
etag TEXT,
|
|
48
|
+
contentType TEXT,
|
|
49
|
+
contentLanguage TEXT,
|
|
50
|
+
contentDisposition TEXT,
|
|
51
|
+
contentEncoding TEXT,
|
|
52
|
+
cacheControl TEXT,
|
|
53
|
+
expires INTEGER,
|
|
54
|
+
lastModified INTEGER,
|
|
55
|
+
size INTEGER,
|
|
56
|
+
storageClass TEXT DEFAULT 'STANDARD',
|
|
57
|
+
versionId TEXT,
|
|
58
|
+
ownerId TEXT DEFAULT '000000000000000000000000',
|
|
59
|
+
ownerDisplayName TEXT DEFAULT 's3mini',
|
|
60
|
+
UNIQUE(bucket, key, versionId)
|
|
61
|
+
)`);
|
|
62
|
+
await this.run(`CREATE TABLE IF NOT EXISTS tags (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
bucket TEXT NOT NULL,
|
|
65
|
+
key TEXT NOT NULL,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
value TEXT NOT NULL,
|
|
68
|
+
UNIQUE(bucket, key, name)
|
|
69
|
+
)`);
|
|
70
|
+
for (const column of ['contentLanguage TEXT', 'encryption TEXT', 'sseKmsKeyId TEXT', 'objectLockMode TEXT', 'retainUntil INTEGER', 'legalHold TEXT', 'deleteMarker INTEGER', 'userMetadata TEXT']) {
|
|
71
|
+
try {
|
|
72
|
+
await this.run(`ALTER TABLE objs ADD COLUMN ${column}`);
|
|
73
|
+
}
|
|
74
|
+
catch { /* Existing databases already have the column. */ }
|
|
75
|
+
}
|
|
76
|
+
await this.run(`CREATE TABLE IF NOT EXISTS multipart_uploads (
|
|
77
|
+
uploadId TEXT PRIMARY KEY,
|
|
78
|
+
bucket TEXT NOT NULL,
|
|
79
|
+
key TEXT NOT NULL,
|
|
80
|
+
initiated INTEGER NOT NULL,
|
|
81
|
+
storageClass TEXT NOT NULL DEFAULT 'STANDARD'
|
|
82
|
+
)`);
|
|
83
|
+
await this.run(`CREATE TABLE IF NOT EXISTS multipart_parts (
|
|
84
|
+
uploadId TEXT NOT NULL,
|
|
85
|
+
partNumber INTEGER NOT NULL,
|
|
86
|
+
etag TEXT NOT NULL,
|
|
87
|
+
size INTEGER NOT NULL,
|
|
88
|
+
lastModified INTEGER NOT NULL,
|
|
89
|
+
body BLOB NOT NULL,
|
|
90
|
+
PRIMARY KEY(uploadId, partNumber)
|
|
91
|
+
)`);
|
|
92
|
+
await this.run(`CREATE TABLE IF NOT EXISTS bucket_settings (
|
|
93
|
+
bucket TEXT PRIMARY KEY,
|
|
94
|
+
versioningStatus TEXT,
|
|
95
|
+
bucketTags TEXT,
|
|
96
|
+
corsConfiguration TEXT,
|
|
97
|
+
lifecycleConfiguration TEXT,
|
|
98
|
+
policy TEXT,
|
|
99
|
+
encryptionConfiguration TEXT,
|
|
100
|
+
websiteConfiguration TEXT,
|
|
101
|
+
loggingStatus TEXT,
|
|
102
|
+
notificationConfiguration TEXT,
|
|
103
|
+
replicationConfiguration TEXT
|
|
104
|
+
,acl TEXT
|
|
105
|
+
)`);
|
|
106
|
+
try {
|
|
107
|
+
await this.run('ALTER TABLE bucket_settings ADD COLUMN acl TEXT');
|
|
108
|
+
}
|
|
109
|
+
catch { /* Existing databases already have the column. */ }
|
|
110
|
+
await this.run(`CREATE TABLE IF NOT EXISTS object_tags (
|
|
111
|
+
bucket TEXT NOT NULL,
|
|
112
|
+
key TEXT NOT NULL,
|
|
113
|
+
versionId TEXT NOT NULL DEFAULT '',
|
|
114
|
+
tags TEXT NOT NULL,
|
|
115
|
+
PRIMARY KEY(bucket, key, versionId)
|
|
116
|
+
)`);
|
|
117
|
+
await this.run(`CREATE TABLE IF NOT EXISTS object_acl (
|
|
118
|
+
bucket TEXT NOT NULL,
|
|
119
|
+
key TEXT NOT NULL,
|
|
120
|
+
versionId TEXT NOT NULL DEFAULT '',
|
|
121
|
+
acl TEXT NOT NULL,
|
|
122
|
+
PRIMARY KEY(bucket, key, versionId)
|
|
123
|
+
)`);
|
|
124
|
+
await this.run(`CREATE TABLE IF NOT EXISTS access_keys (
|
|
125
|
+
accessKeyId TEXT PRIMARY KEY,
|
|
126
|
+
secretAccessKey TEXT NOT NULL,
|
|
127
|
+
displayName TEXT NOT NULL DEFAULT '',
|
|
128
|
+
status TEXT NOT NULL DEFAULT 'Active',
|
|
129
|
+
createdAt INTEGER NOT NULL,
|
|
130
|
+
lastUsedAt INTEGER
|
|
131
|
+
)`);
|
|
132
|
+
await this.run(`CREATE TABLE IF NOT EXISTS replication_events (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
bucket TEXT NOT NULL,
|
|
135
|
+
key TEXT NOT NULL,
|
|
136
|
+
versionId TEXT NOT NULL,
|
|
137
|
+
operation TEXT NOT NULL,
|
|
138
|
+
sourceNodeId TEXT NOT NULL,
|
|
139
|
+
payloadPath TEXT NOT NULL,
|
|
140
|
+
etag TEXT NOT NULL,
|
|
141
|
+
size INTEGER NOT NULL,
|
|
142
|
+
status TEXT NOT NULL DEFAULT 'Pending',
|
|
143
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
144
|
+
nextAttemptAt INTEGER,
|
|
145
|
+
deleteMarker INTEGER NOT NULL DEFAULT 0,
|
|
146
|
+
sha256 TEXT,
|
|
147
|
+
leaseOwner TEXT,
|
|
148
|
+
leaseUntil INTEGER,
|
|
149
|
+
createdAt INTEGER NOT NULL,
|
|
150
|
+
UNIQUE(bucket, key, versionId, operation, sourceNodeId)
|
|
151
|
+
)`);
|
|
152
|
+
try {
|
|
153
|
+
await this.run('ALTER TABLE replication_events ADD COLUMN deleteMarker INTEGER NOT NULL DEFAULT 0');
|
|
154
|
+
}
|
|
155
|
+
catch { /* Existing databases already have the column. */ }
|
|
156
|
+
try {
|
|
157
|
+
await this.run('ALTER TABLE replication_events ADD COLUMN sha256 TEXT');
|
|
158
|
+
}
|
|
159
|
+
catch { /* Existing databases already have the column. */ }
|
|
160
|
+
try {
|
|
161
|
+
await this.run('ALTER TABLE replication_events ADD COLUMN leaseOwner TEXT');
|
|
162
|
+
}
|
|
163
|
+
catch { /* Existing databases already have the column. */ }
|
|
164
|
+
try {
|
|
165
|
+
await this.run('ALTER TABLE replication_events ADD COLUMN leaseUntil INTEGER');
|
|
166
|
+
}
|
|
167
|
+
catch { /* Existing databases already have the column. */ }
|
|
168
|
+
await this.run(`CREATE TABLE IF NOT EXISTS replication_peer_health (
|
|
169
|
+
peer TEXT PRIMARY KEY,
|
|
170
|
+
status TEXT NOT NULL DEFAULT 'Unknown',
|
|
171
|
+
consecutiveFailures INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
lastSuccessAt INTEGER,
|
|
173
|
+
lastFailureAt INTEGER
|
|
174
|
+
)`);
|
|
175
|
+
this.initialized = true;
|
|
176
|
+
}
|
|
177
|
+
async run(sql, params = []) {
|
|
178
|
+
this.db?.prepare(sql).run(...params);
|
|
179
|
+
}
|
|
180
|
+
async get(sql, params = []) {
|
|
181
|
+
return this.db?.prepare(sql).get(...params);
|
|
182
|
+
}
|
|
183
|
+
async all(sql, params = []) {
|
|
184
|
+
return (this.db?.prepare(sql).all(...params) || []);
|
|
185
|
+
}
|
|
186
|
+
async listBuckets() {
|
|
187
|
+
const rows = await this.all('SELECT * FROM buckets');
|
|
188
|
+
return rows.map((r) => ({
|
|
189
|
+
name: r.name,
|
|
190
|
+
locationConstraint: r.locationConstraint,
|
|
191
|
+
creationDate: new Date(r.creationDate),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
async applyLifecycle(bucket) {
|
|
195
|
+
const configuration = await this.getBucketConfiguration(bucket, 'lifecycleConfiguration');
|
|
196
|
+
if (!configuration?.rules?.length)
|
|
197
|
+
return;
|
|
198
|
+
const rows = await this.all('SELECT * FROM objs WHERE bucket = ?', [bucket]);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
const versioning = await this.getVersioning(bucket);
|
|
201
|
+
const rowsByKey = new Map();
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
const versions = rowsByKey.get(row.key) || [];
|
|
204
|
+
versions.push(row);
|
|
205
|
+
rowsByKey.set(row.key, versions);
|
|
206
|
+
}
|
|
207
|
+
for (const [key, versions] of rowsByKey) {
|
|
208
|
+
versions.sort((left, right) => right.id - left.id);
|
|
209
|
+
for (const [index, row] of versions.entries()) {
|
|
210
|
+
const noncurrent = index > 0;
|
|
211
|
+
const rule = configuration.rules.find(candidate => lifecycleRuleMatches(candidate, row.key));
|
|
212
|
+
if (!rule)
|
|
213
|
+
continue;
|
|
214
|
+
const transition = lifecycleTransition(rule, noncurrent, row.lastModified, now);
|
|
215
|
+
if (transition && row.storageClass !== transition) {
|
|
216
|
+
await this.run('UPDATE objs SET storageClass = ? WHERE id = ?', [transition, row.id]);
|
|
217
|
+
}
|
|
218
|
+
const expiration = noncurrent ? rule.noncurrentVersionExpiration : rule.expiration;
|
|
219
|
+
if (!expiration || !lifecycleAgeReached(expiration, row.lastModified, now))
|
|
220
|
+
continue;
|
|
221
|
+
if (row.deleteMarker && !expiration.expiredObjectDeleteMarker)
|
|
222
|
+
continue;
|
|
223
|
+
if (noncurrent || !versioning.status || row.deleteMarker) {
|
|
224
|
+
await this.removeObjectVersion(bucket, row, !noncurrent && !versioning.status);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// Versioned lifecycle expiration hides the current version with a marker.
|
|
228
|
+
await this.run('INSERT INTO objs (bucket, key, etag, lastModified, size, storageClass, versionId, ownerId, ownerDisplayName, deleteMarker) VALUES (?, ?, NULL, ?, 0, ?, ?, ?, ?, 1)', [bucket, key, now, 'STANDARD', crypto.randomBytes(8).toString('hex') + '+', '000000000000000000000000', 's3mini']);
|
|
229
|
+
await fs.rm(path.join(STORAGE_BASE, bucket, key), { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async removeObjectVersion(bucket, row, removeCurrentFile = false) {
|
|
234
|
+
await this.run('DELETE FROM objs WHERE id = ?', [row.id]);
|
|
235
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, row.key, row.versionId]);
|
|
236
|
+
await this.run('DELETE FROM object_acl WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, row.key, row.versionId]);
|
|
237
|
+
await fs.rm(this.versionPath(bucket, row.key, row.versionId), { force: true });
|
|
238
|
+
if (row.deleteMarker || removeCurrentFile)
|
|
239
|
+
await fs.rm(path.join(STORAGE_BASE, bucket, row.key), { force: true });
|
|
240
|
+
}
|
|
241
|
+
async createBucket(name, locationConstraint = 'us-east-1') {
|
|
242
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(name) || name.includes('..') || name.includes('.-') || name.includes('-.')) {
|
|
243
|
+
throw new S3Error('InvalidBucketName', 'The bucket name is invalid.', 400, name);
|
|
244
|
+
}
|
|
245
|
+
if (!VALID_LOCATION_CONSTRAINTS.includes(locationConstraint)) {
|
|
246
|
+
throw new S3Error('InvalidLocationConstraint', 'The location constraint is invalid.', 400, name);
|
|
247
|
+
}
|
|
248
|
+
const exists = await this.get('SELECT name FROM buckets WHERE name = ?', [name]);
|
|
249
|
+
if (exists)
|
|
250
|
+
throw new S3Error('BucketAlreadyExists', 'Bucket already exists.', 409, name);
|
|
251
|
+
await this.run('INSERT INTO buckets (name, locationConstraint, creationDate) VALUES (?, ?, ?)', [name, locationConstraint, Date.now()]);
|
|
252
|
+
await this.run('INSERT INTO bucket_settings (bucket, versioningStatus) VALUES (?, ?)', [name, null]);
|
|
253
|
+
await fs.mkdir(path.join(STORAGE_BASE, name), { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
async headBucket(name) {
|
|
256
|
+
const bucket = await this.get('SELECT name FROM buckets WHERE name = ?', [name]);
|
|
257
|
+
if (!bucket)
|
|
258
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, name);
|
|
259
|
+
}
|
|
260
|
+
async deleteBucket(name) {
|
|
261
|
+
const bucket = await this.get('SELECT name FROM buckets WHERE name = ?', [name]);
|
|
262
|
+
if (!bucket)
|
|
263
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, name);
|
|
264
|
+
const object = await this.get('SELECT id FROM objs WHERE bucket = ? LIMIT 1', [name]);
|
|
265
|
+
if (object)
|
|
266
|
+
throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty.', 409, name);
|
|
267
|
+
await this.run('DELETE FROM objs WHERE bucket = ?', [name]);
|
|
268
|
+
await this.run('DELETE FROM tags WHERE bucket = ?', [name]);
|
|
269
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ?', [name]);
|
|
270
|
+
await this.run('DELETE FROM object_acl WHERE bucket = ?', [name]);
|
|
271
|
+
await this.run('DELETE FROM replication_events WHERE bucket = ?', [name]);
|
|
272
|
+
await this.run('DELETE FROM bucket_settings WHERE bucket = ?', [name]);
|
|
273
|
+
await this.run('DELETE FROM buckets WHERE name = ?', [name]);
|
|
274
|
+
await fs.rm(path.join(STORAGE_BASE, name), { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
async close() {
|
|
277
|
+
if (!this.db)
|
|
278
|
+
return;
|
|
279
|
+
this.db.close();
|
|
280
|
+
this.db = undefined;
|
|
281
|
+
this.initialized = false;
|
|
282
|
+
}
|
|
283
|
+
async createAccessKey(displayName = '') {
|
|
284
|
+
const accessKeyId = `s3mini-${crypto.randomBytes(12).toString('base64url')}`;
|
|
285
|
+
const secretAccessKey = crypto.randomBytes(32).toString('base64url');
|
|
286
|
+
await this.run('INSERT INTO access_keys (accessKeyId, secretAccessKey, displayName, status, createdAt) VALUES (?, ?, ?, ?, ?)', [accessKeyId, secretAccessKey, displayName, 'Active', Date.now()]);
|
|
287
|
+
return { accessKeyId, secretAccessKey };
|
|
288
|
+
}
|
|
289
|
+
async listAccessKeys() {
|
|
290
|
+
const rows = await this.all('SELECT accessKeyId, displayName, status, createdAt, lastUsedAt FROM access_keys ORDER BY createdAt DESC');
|
|
291
|
+
return rows.map(row => ({
|
|
292
|
+
accessKeyId: row.accessKeyId,
|
|
293
|
+
displayName: row.displayName,
|
|
294
|
+
status: row.status,
|
|
295
|
+
createdAt: new Date(row.createdAt),
|
|
296
|
+
lastUsedAt: row.lastUsedAt ? new Date(row.lastUsedAt) : undefined,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
async getAccessKey(accessKeyId) {
|
|
300
|
+
const row = await this.get('SELECT accessKeyId, secretAccessKey, status FROM access_keys WHERE accessKeyId = ?', [accessKeyId]);
|
|
301
|
+
return row ? { accessKeyId: row.accessKeyId, secretAccessKey: row.secretAccessKey, status: row.status } : undefined;
|
|
302
|
+
}
|
|
303
|
+
async setAccessKeyStatus(accessKeyId, status) {
|
|
304
|
+
await this.run('UPDATE access_keys SET status = ? WHERE accessKeyId = ?', [status, accessKeyId]);
|
|
305
|
+
}
|
|
306
|
+
async markAccessKeyUsed(accessKeyId) {
|
|
307
|
+
await this.run('UPDATE access_keys SET lastUsedAt = ? WHERE accessKeyId = ?', [Date.now(), accessKeyId]);
|
|
308
|
+
}
|
|
309
|
+
async hasActiveAccessKeys() {
|
|
310
|
+
return Boolean(await this.get("SELECT 1 FROM access_keys WHERE status = 'Active' LIMIT 1"));
|
|
311
|
+
}
|
|
312
|
+
async listReplicationEvents(limit = 100, status) {
|
|
313
|
+
const rows = status
|
|
314
|
+
? await this.all('SELECT * FROM replication_events WHERE status = ? ORDER BY id ASC LIMIT ?', [status, Math.max(1, Math.min(limit, 1000))])
|
|
315
|
+
: await this.all('SELECT * FROM replication_events ORDER BY id ASC LIMIT ?', [Math.max(1, Math.min(limit, 1000))]);
|
|
316
|
+
return rows.map(row => this.replicationEventFromRow(row));
|
|
317
|
+
}
|
|
318
|
+
replicationEventFromRow(row) {
|
|
319
|
+
return {
|
|
320
|
+
id: row.id,
|
|
321
|
+
bucket: row.bucket,
|
|
322
|
+
key: row.key,
|
|
323
|
+
versionId: row.versionId,
|
|
324
|
+
operation: row.operation,
|
|
325
|
+
sourceNodeId: row.sourceNodeId,
|
|
326
|
+
payloadPath: row.payloadPath,
|
|
327
|
+
etag: row.etag,
|
|
328
|
+
sha256: row.sha256 || undefined,
|
|
329
|
+
size: row.size,
|
|
330
|
+
deleteMarker: Boolean(row.deleteMarker),
|
|
331
|
+
status: row.status,
|
|
332
|
+
attempts: row.attempts,
|
|
333
|
+
nextAttemptAt: row.nextAttemptAt ? new Date(row.nextAttemptAt) : undefined,
|
|
334
|
+
createdAt: new Date(row.createdAt),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
async listReplicationInventory(limit = 1000) {
|
|
338
|
+
const rows = await this.all('SELECT bucket, key, versionId, etag, size, lastModified, deleteMarker FROM objs ORDER BY bucket, key, lastModified, id LIMIT ?', [Math.max(1, Math.min(limit, 10000))]);
|
|
339
|
+
return Promise.all(rows.map(async (row) => ({
|
|
340
|
+
bucket: row.bucket,
|
|
341
|
+
key: row.key,
|
|
342
|
+
versionId: row.versionId,
|
|
343
|
+
etag: row.etag || '',
|
|
344
|
+
sha256: row.deleteMarker ? '' : crypto.createHash('sha256').update(await fs.readFile(this.versionPath(row.bucket, row.key, row.versionId))).digest('hex'),
|
|
345
|
+
size: row.size,
|
|
346
|
+
lastModified: new Date(row.lastModified),
|
|
347
|
+
deleteMarker: Boolean(row.deleteMarker),
|
|
348
|
+
})));
|
|
349
|
+
}
|
|
350
|
+
async listPeerHealth() {
|
|
351
|
+
const rows = await this.all('SELECT * FROM replication_peer_health ORDER BY peer ASC');
|
|
352
|
+
return rows.map(row => ({
|
|
353
|
+
peer: row.peer,
|
|
354
|
+
status: row.status,
|
|
355
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
356
|
+
lastSuccessAt: row.lastSuccessAt ? new Date(row.lastSuccessAt) : undefined,
|
|
357
|
+
lastFailureAt: row.lastFailureAt ? new Date(row.lastFailureAt) : undefined,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
async savePeerHealth(state) {
|
|
361
|
+
await this.run(`INSERT INTO replication_peer_health (peer, status, consecutiveFailures, lastSuccessAt, lastFailureAt)
|
|
362
|
+
VALUES (?, ?, ?, ?, ?)
|
|
363
|
+
ON CONFLICT(peer) DO UPDATE SET status = excluded.status, consecutiveFailures = excluded.consecutiveFailures, lastSuccessAt = excluded.lastSuccessAt, lastFailureAt = excluded.lastFailureAt`, [
|
|
364
|
+
state.peer, state.status, state.consecutiveFailures, state.lastSuccessAt?.getTime() || null, state.lastFailureAt?.getTime() || null,
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
async updateReplicationEvent(id, status, attempts, nextAttemptAt) {
|
|
368
|
+
await this.run('UPDATE replication_events SET status = ?, attempts = ?, nextAttemptAt = ?, leaseOwner = NULL, leaseUntil = NULL WHERE id = ?', [status, attempts, nextAttemptAt?.getTime() || null, id]);
|
|
369
|
+
}
|
|
370
|
+
async retryReplicationEvent(id) {
|
|
371
|
+
await this.run("UPDATE replication_events SET status = 'Pending', attempts = 0, nextAttemptAt = NULL, leaseOwner = NULL, leaseUntil = NULL WHERE id = ? AND status = 'DeadLetter'", [id]);
|
|
372
|
+
}
|
|
373
|
+
async claimReplicationEvents(owner, limit = 100, leaseMs = 60_000) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
this.db?.exec('BEGIN IMMEDIATE');
|
|
376
|
+
try {
|
|
377
|
+
await this.run(`UPDATE replication_events SET leaseOwner = ?, leaseUntil = ?
|
|
378
|
+
WHERE id IN (SELECT id FROM replication_events
|
|
379
|
+
WHERE status NOT IN ('Delivered', 'DeadLetter') AND (nextAttemptAt IS NULL OR nextAttemptAt <= ?)
|
|
380
|
+
AND (leaseUntil IS NULL OR leaseUntil <= ?)
|
|
381
|
+
ORDER BY id ASC LIMIT ?)`, [owner, now + leaseMs, now, now, Math.max(1, Math.min(limit, 1000))]);
|
|
382
|
+
const rows = await this.all('SELECT * FROM replication_events WHERE leaseOwner = ? AND leaseUntil = ? ORDER BY id ASC', [owner, now + leaseMs]);
|
|
383
|
+
this.db?.exec('COMMIT');
|
|
384
|
+
return rows.map(row => this.replicationEventFromRow(row));
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
this.db?.exec('ROLLBACK');
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async queueReplicationEvent(event) {
|
|
392
|
+
await this.run('INSERT OR IGNORE INTO replication_events (bucket, key, versionId, operation, sourceNodeId, payloadPath, etag, size, deleteMarker, sha256, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [event.bucket, event.key, event.versionId, event.operation, event.sourceNodeId, event.payloadPath, event.etag, event.size, event.deleteMarker ? 1 : 0, event.sha256 || null, event.createdAt.getTime()]);
|
|
393
|
+
}
|
|
394
|
+
async readReplicationPayload(event) {
|
|
395
|
+
return fs.readFile(event.payloadPath);
|
|
396
|
+
}
|
|
397
|
+
async acceptReplicatedObject(object) {
|
|
398
|
+
this.validateKey(object.key);
|
|
399
|
+
if (!/^[A-Za-z0-9._+-]+$/.test(object.versionId))
|
|
400
|
+
throw new S3Error('InvalidRequest', 'The replicated version ID is invalid.', 400, object.bucket, object.key);
|
|
401
|
+
if ('"' + crypto.createHash('md5').update(object.body).digest('hex') + '"' !== object.etag)
|
|
402
|
+
throw new S3Error('BadDigest', 'The replicated object ETag did not match its body.', 400, object.bucket, object.key);
|
|
403
|
+
const sha256 = crypto.createHash('sha256').update(object.body).digest('hex');
|
|
404
|
+
if (object.sha256 && object.sha256 !== sha256)
|
|
405
|
+
throw new S3Error('BadDigest', 'The replicated object SHA-256 digest did not match its body.', 400, object.bucket, object.key);
|
|
406
|
+
if (!(await this.get('SELECT name FROM buckets WHERE name = ?', [object.bucket])))
|
|
407
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, object.bucket);
|
|
408
|
+
if (await this.get('SELECT id FROM objs WHERE bucket = ? AND key = ? AND versionId = ?', [object.bucket, object.key, object.versionId]))
|
|
409
|
+
return;
|
|
410
|
+
const filePath = path.join(STORAGE_BASE, object.bucket, object.key);
|
|
411
|
+
const versionFilePath = this.versionPath(object.bucket, object.key, object.versionId);
|
|
412
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
413
|
+
await fs.mkdir(path.dirname(versionFilePath), { recursive: true });
|
|
414
|
+
await writeDurableFile(versionFilePath, object.body);
|
|
415
|
+
const current = await this.get('SELECT lastModified FROM objs WHERE bucket = ? AND key = ? ORDER BY lastModified DESC, id DESC LIMIT 1', [object.bucket, object.key]);
|
|
416
|
+
if (!current || object.lastModified >= current.lastModified)
|
|
417
|
+
await writeDurableFile(filePath, object.body);
|
|
418
|
+
this.db?.exec('BEGIN IMMEDIATE');
|
|
419
|
+
try {
|
|
420
|
+
await this.run('INSERT INTO objs (bucket, key, etag, contentType, lastModified, size, storageClass, versionId, ownerId, ownerDisplayName, deleteMarker, userMetadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [object.bucket, object.key, object.etag, 'application/octet-stream', object.lastModified, object.body.length, 'STANDARD', object.versionId, '000000000000000000000000', 's3mini', 0, '{}']);
|
|
421
|
+
this.db?.exec('COMMIT');
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
this.db?.exec('ROLLBACK');
|
|
425
|
+
await fs.rm(versionFilePath, { force: true });
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async acceptReplicatedDelete(event) {
|
|
430
|
+
this.validateKey(event.key);
|
|
431
|
+
if (!(await this.get('SELECT name FROM buckets WHERE name = ?', [event.bucket])))
|
|
432
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, event.bucket);
|
|
433
|
+
if (event.deleteMarker) {
|
|
434
|
+
if (await this.get('SELECT id FROM objs WHERE bucket = ? AND key = ? AND versionId = ?', [event.bucket, event.key, event.versionId]))
|
|
435
|
+
return;
|
|
436
|
+
await this.run('INSERT INTO objs (bucket, key, etag, lastModified, size, storageClass, versionId, ownerId, ownerDisplayName, deleteMarker, userMetadata) VALUES (?, ?, NULL, ?, 0, ?, ?, ?, ?, 1, ?)', [event.bucket, event.key, event.lastModified, 'STANDARD', event.versionId, '000000000000000000000000', 's3mini', '{}']);
|
|
437
|
+
await fs.rm(path.join(STORAGE_BASE, event.bucket, event.key), { force: true });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (event.versionId) {
|
|
441
|
+
const row = await this.get('SELECT id FROM objs WHERE bucket = ? AND key = ? AND versionId = ?', [event.bucket, event.key, event.versionId]);
|
|
442
|
+
if (row)
|
|
443
|
+
await this.deleteReplicatedVersion(event.bucket, event.key, event.versionId, row.id);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
await this.run('DELETE FROM objs WHERE bucket = ? AND key = ?', [event.bucket, event.key]);
|
|
447
|
+
await fs.rm(path.join(STORAGE_BASE, event.bucket, event.key), { force: true });
|
|
448
|
+
}
|
|
449
|
+
async deleteReplicatedVersion(bucket, key, versionId, id) {
|
|
450
|
+
await this.run('DELETE FROM objs WHERE id = ?', [id]);
|
|
451
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
452
|
+
await fs.rm(this.versionPath(bucket, key, versionId), { force: true });
|
|
453
|
+
}
|
|
454
|
+
async clearBucket(name) {
|
|
455
|
+
await this.run('DELETE FROM objs WHERE bucket = ?', [name]);
|
|
456
|
+
await this.run('DELETE FROM tags WHERE bucket = ?', [name]);
|
|
457
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ?', [name]);
|
|
458
|
+
await this.run('DELETE FROM object_acl WHERE bucket = ?', [name]);
|
|
459
|
+
await this.run('DELETE FROM replication_events WHERE bucket = ?', [name]);
|
|
460
|
+
await this.run('DELETE FROM bucket_settings WHERE bucket = ?', [name]);
|
|
461
|
+
await this.run('DELETE FROM buckets WHERE name = ?', [name]);
|
|
462
|
+
await fs.rm(path.join(STORAGE_BASE, name), { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
async putObject(bucketName, key, body, meta) {
|
|
465
|
+
this.validateKey(key);
|
|
466
|
+
const b = await this.get('SELECT name FROM buckets WHERE name = ?', [bucketName]);
|
|
467
|
+
if (!b)
|
|
468
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, bucketName);
|
|
469
|
+
const versionId = crypto.randomBytes(8).toString('hex') + '+';
|
|
470
|
+
const etag = '"' + crypto.createHash('md5').update(body).digest('hex') + '"';
|
|
471
|
+
const lastModified = Date.now();
|
|
472
|
+
const filePath = path.join(STORAGE_BASE, bucketName, key);
|
|
473
|
+
const versionFilePath = this.versionPath(bucketName, key, versionId);
|
|
474
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
475
|
+
await fs.mkdir(path.dirname(versionFilePath), { recursive: true });
|
|
476
|
+
await writeDurableFile(filePath, body);
|
|
477
|
+
await writeDurableFile(versionFilePath, body);
|
|
478
|
+
this.db?.exec('BEGIN IMMEDIATE');
|
|
479
|
+
try {
|
|
480
|
+
await this.run(`
|
|
481
|
+
INSERT INTO objs (bucket, key, etag, contentType, contentLanguage, contentDisposition, contentEncoding, cacheControl, expires, lastModified, size, storageClass, versionId, ownerId, ownerDisplayName, encryption, sseKmsKeyId, objectLockMode, retainUntil, legalHold, deleteMarker, userMetadata)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
483
|
+
bucketName, key, etag, meta.contentType || null, meta.contentLanguage || null, meta.contentDisposition || null,
|
|
484
|
+
meta.contentEncoding || null, meta.cacheControl || null,
|
|
485
|
+
meta.expires ? new Date(meta.expires).getTime() : null,
|
|
486
|
+
lastModified, body.length, meta.storageClass || 'STANDARD', versionId, '000000000000000000000000', 's3mini', meta.serverSideEncryption || null, meta.sseKmsKeyId || null, meta.objectLockMode || null, meta.retainUntil ? new Date(meta.retainUntil).getTime() : null, meta.legalHold || null, 0, JSON.stringify(meta.userMetadata || {})
|
|
487
|
+
]);
|
|
488
|
+
await this.queueReplicationEvent({ bucket: bucketName, key, versionId, operation: 'PutObject', sourceNodeId: NODE_ID, payloadPath: versionFilePath, etag, sha256: crypto.createHash('sha256').update(body).digest('hex'), size: body.length, deleteMarker: false, createdAt: new Date(lastModified) });
|
|
489
|
+
this.db?.exec('COMMIT');
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
this.db?.exec('ROLLBACK');
|
|
493
|
+
await fs.rm(filePath, { force: true });
|
|
494
|
+
await fs.rm(versionFilePath, { force: true });
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
key, bucket: bucketName, versionId, etag, contentType: meta.contentType || 'application/octet-stream', contentLanguage: meta.contentLanguage,
|
|
499
|
+
contentDisposition: meta.contentDisposition, contentEncoding: meta.contentEncoding,
|
|
500
|
+
cacheControl: meta.cacheControl, expires: meta.expires ? new Date(meta.expires) : undefined,
|
|
501
|
+
lastModified: new Date(lastModified), storageClass: meta.storageClass || 'STANDARD',
|
|
502
|
+
ownerId: '000000000000000000000000', ownerDisplayName: 's3mini',
|
|
503
|
+
userMetadata: meta.userMetadata || {},
|
|
504
|
+
serverSideEncryption: meta.serverSideEncryption,
|
|
505
|
+
sseKmsKeyId: meta.sseKmsKeyId,
|
|
506
|
+
objectLockMode: meta.objectLockMode,
|
|
507
|
+
objectLockRetainUntilDate: meta.retainUntil ? new Date(meta.retainUntil) : undefined,
|
|
508
|
+
objectLockLegalHold: meta.legalHold === 'ON',
|
|
509
|
+
size: body.length // FIX: added missing size
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async getObject(bucketName, key, versionId) {
|
|
513
|
+
this.validateKey(key);
|
|
514
|
+
await this.applyLifecycle(bucketName);
|
|
515
|
+
const row = versionId
|
|
516
|
+
? await this.get('SELECT * FROM objs WHERE bucket = ? AND key = ? AND versionId = ?', [bucketName, key, versionId])
|
|
517
|
+
: await this.get('SELECT * FROM objs WHERE bucket = ? AND key = ? ORDER BY lastModified DESC, id DESC LIMIT 1', [bucketName, key]);
|
|
518
|
+
if (!row)
|
|
519
|
+
throw new S3Error('NoSuchKey', 'Object not found', 404, bucketName, key);
|
|
520
|
+
if (row.deleteMarker)
|
|
521
|
+
throw new S3Error('NoSuchKey', 'The object is deleted.', 404, bucketName, key);
|
|
522
|
+
const versionFilePath = this.versionPath(bucketName, key, row.versionId);
|
|
523
|
+
const filePath = await fs.stat(versionFilePath).then(() => versionFilePath).catch(() => path.join(STORAGE_BASE, bucketName, key));
|
|
524
|
+
const data = await fs.readFile(filePath);
|
|
525
|
+
const metadata = {
|
|
526
|
+
key: row.key, bucket: row.bucket, versionId: row.versionId, size: row.size, etag: row.etag,
|
|
527
|
+
contentType: row.contentType || 'application/octet-stream', contentLanguage: row.contentLanguage || undefined, contentDisposition: row.contentDisposition || undefined,
|
|
528
|
+
contentEncoding: row.contentEncoding || undefined, cacheControl: row.cacheControl || undefined,
|
|
529
|
+
expires: row.expires ? new Date(row.expires) : undefined, lastModified: new Date(row.lastModified),
|
|
530
|
+
storageClass: row.storageClass, ownerId: row.ownerId, ownerDisplayName: row.ownerDisplayName,
|
|
531
|
+
userMetadata: row.userMetadata ? JSON.parse(row.userMetadata) : {}, serverSideEncryption: row.encryption || undefined, sseKmsKeyId: row.sseKmsKeyId || undefined,
|
|
532
|
+
objectLockMode: row.objectLockMode || undefined, objectLockRetainUntilDate: row.retainUntil ? new Date(row.retainUntil) : undefined, objectLockLegalHold: row.legalHold === 'ON'
|
|
533
|
+
};
|
|
534
|
+
return { data, metadata };
|
|
535
|
+
}
|
|
536
|
+
async getObjectRange(bucketName, key, start, end) {
|
|
537
|
+
const object = await this.getObject(bucketName, key);
|
|
538
|
+
if (start < 0 || start >= object.data.length || (end !== undefined && end < start)) {
|
|
539
|
+
throw new S3Error('InvalidRange', 'The requested range is not satisfiable.', 416, bucketName, key);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
data: object.data.subarray(start, end === undefined ? undefined : end + 1),
|
|
543
|
+
metadata: object.metadata,
|
|
544
|
+
totalSize: object.data.length,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async copyObject(sourceBucket, sourceKey, destinationBucket, destinationKey) {
|
|
548
|
+
const source = await this.getObject(sourceBucket, sourceKey);
|
|
549
|
+
return this.putObject(destinationBucket, destinationKey, source.data, {
|
|
550
|
+
contentType: source.metadata.contentType,
|
|
551
|
+
contentLanguage: source.metadata.contentLanguage,
|
|
552
|
+
contentDisposition: source.metadata.contentDisposition,
|
|
553
|
+
contentEncoding: source.metadata.contentEncoding,
|
|
554
|
+
cacheControl: source.metadata.cacheControl,
|
|
555
|
+
expires: source.metadata.expires,
|
|
556
|
+
storageClass: source.metadata.storageClass,
|
|
557
|
+
userMetadata: source.metadata.userMetadata,
|
|
558
|
+
serverSideEncryption: source.metadata.serverSideEncryption,
|
|
559
|
+
sseKmsKeyId: source.metadata.sseKmsKeyId,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async restoreObject(bucket, key) {
|
|
563
|
+
await this.headBucket(bucket);
|
|
564
|
+
await this.getObject(bucket, key);
|
|
565
|
+
}
|
|
566
|
+
async selectObjectContent(bucket, key, expression) {
|
|
567
|
+
if (!/^\s*SELECT\s+\*\s+FROM\s+S3Object\s*;?\s*$/i.test(expression)) {
|
|
568
|
+
throw new S3Error('InvalidExpression', 'Only SELECT * FROM S3Object is supported.', 400, bucket, key);
|
|
569
|
+
}
|
|
570
|
+
return (await this.getObject(bucket, key)).data;
|
|
571
|
+
}
|
|
572
|
+
async deleteObject(bucketName, key) {
|
|
573
|
+
this.validateKey(key);
|
|
574
|
+
const found = await this.get('SELECT id, objectLockMode, retainUntil, legalHold FROM objs WHERE bucket = ? AND key = ?', [bucketName, key]);
|
|
575
|
+
if (!found)
|
|
576
|
+
throw new S3Error('NoSuchKey', 'Object not found', 404, bucketName, key);
|
|
577
|
+
if (found.legalHold === 'ON' || (found.retainUntil && found.retainUntil > Date.now()))
|
|
578
|
+
throw new S3Error('AccessDenied', 'Object retention prevents deletion.', 403, bucketName, key);
|
|
579
|
+
const versioning = await this.getVersioning(bucketName);
|
|
580
|
+
if (versioning.status === 'Enabled') {
|
|
581
|
+
const markerId = crypto.randomBytes(8).toString('hex') + '+';
|
|
582
|
+
const deletedAt = Date.now();
|
|
583
|
+
await this.run('INSERT INTO objs (bucket, key, etag, lastModified, size, storageClass, versionId, ownerId, ownerDisplayName, deleteMarker) VALUES (?, ?, NULL, ?, 0, ?, ?, ?, ?, 1)', [bucketName, key, deletedAt, 'STANDARD', markerId, '000000000000000000000000', 's3mini']);
|
|
584
|
+
await this.queueReplicationEvent({ bucket: bucketName, key, versionId: markerId, operation: 'DeleteObject', sourceNodeId: NODE_ID, payloadPath: '', etag: '', size: 0, deleteMarker: true, createdAt: new Date(deletedAt) });
|
|
585
|
+
await fs.rm(path.join(STORAGE_BASE, bucketName, key), { force: true });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
await this.run('DELETE FROM objs WHERE bucket = ? AND key = ?', [bucketName, key]);
|
|
589
|
+
await this.run('DELETE FROM tags WHERE bucket = ? AND key = ?', [bucketName, key]);
|
|
590
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ? AND key = ?', [bucketName, key]);
|
|
591
|
+
await this.run('DELETE FROM object_acl WHERE bucket = ? AND key = ?', [bucketName, key]);
|
|
592
|
+
await fs.rm(path.join(STORAGE_BASE, bucketName, key), { force: true });
|
|
593
|
+
await fs.rm(path.join(STORAGE_BASE, bucketName, '.versions'), { recursive: true, force: true });
|
|
594
|
+
await this.queueReplicationEvent({ bucket: bucketName, key, versionId: '', operation: 'DeleteObject', sourceNodeId: NODE_ID, payloadPath: '', etag: '', size: 0, deleteMarker: false, createdAt: new Date() });
|
|
595
|
+
}
|
|
596
|
+
async deleteObjects(bucket, keys) {
|
|
597
|
+
await this.headBucket(bucket);
|
|
598
|
+
const deleted = [];
|
|
599
|
+
const errors = [];
|
|
600
|
+
for (const key of keys) {
|
|
601
|
+
try {
|
|
602
|
+
await this.deleteObject(bucket, key);
|
|
603
|
+
deleted.push(key);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
if (error instanceof S3Error && error.code === 'NoSuchKey')
|
|
607
|
+
deleted.push(key);
|
|
608
|
+
else
|
|
609
|
+
errors.push({ key, code: error instanceof S3Error ? error.code : 'InternalError' });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return { deleted, errors };
|
|
613
|
+
}
|
|
614
|
+
async getVersioning(bucket) {
|
|
615
|
+
await this.headBucket(bucket);
|
|
616
|
+
const row = await this.get('SELECT versioningStatus FROM bucket_settings WHERE bucket = ?', [bucket]);
|
|
617
|
+
return row?.versioningStatus ? { status: row.versioningStatus } : {};
|
|
618
|
+
}
|
|
619
|
+
async putVersioning(bucket, status) {
|
|
620
|
+
await this.headBucket(bucket);
|
|
621
|
+
await this.run('UPDATE bucket_settings SET versioningStatus = ? WHERE bucket = ?', [status, bucket]);
|
|
622
|
+
}
|
|
623
|
+
async listObjectVersions(bucket, prefix) {
|
|
624
|
+
await this.headBucket(bucket);
|
|
625
|
+
const rows = await this.all('SELECT * FROM objs WHERE bucket = ? ORDER BY key ASC, id DESC', [bucket]);
|
|
626
|
+
return rows
|
|
627
|
+
.filter(row => !prefix || row.key.startsWith(prefix))
|
|
628
|
+
.map((row, index, all) => ({
|
|
629
|
+
Key: row.key,
|
|
630
|
+
VersionId: row.versionId,
|
|
631
|
+
IsLatest: index === all.findIndex(candidate => candidate.key === row.key),
|
|
632
|
+
LastModified: new Date(row.lastModified),
|
|
633
|
+
ETag: row.etag,
|
|
634
|
+
Size: row.size,
|
|
635
|
+
StorageClass: row.storageClass,
|
|
636
|
+
IsDeleteMarker: Boolean(row.deleteMarker),
|
|
637
|
+
Owner: { ID: row.ownerId, DisplayName: row.ownerDisplayName },
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
async deleteObjectVersion(bucket, key, versionId) {
|
|
641
|
+
this.validateKey(key);
|
|
642
|
+
await this.headBucket(bucket);
|
|
643
|
+
const row = await this.get('SELECT id FROM objs WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
644
|
+
if (!row)
|
|
645
|
+
throw new S3Error('NoSuchVersion', 'The specified version does not exist.', 404, bucket, key);
|
|
646
|
+
await this.run('DELETE FROM objs WHERE id = ?', [row.id]);
|
|
647
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
648
|
+
await fs.rm(this.versionPath(bucket, key, versionId), { force: true });
|
|
649
|
+
await this.queueReplicationEvent({ bucket, key, versionId, operation: 'DeleteObject', sourceNodeId: NODE_ID, payloadPath: '', etag: '', size: 0, deleteMarker: false, createdAt: new Date() });
|
|
650
|
+
}
|
|
651
|
+
async putObjectTags(bucket, key, tags, versionId = '') {
|
|
652
|
+
await this.headBucket(bucket);
|
|
653
|
+
const object = await this.get('SELECT id FROM objs WHERE bucket = ? AND key = ? AND (? = \'\' OR versionId = ?) LIMIT 1', [bucket, key, versionId, versionId]);
|
|
654
|
+
if (!object)
|
|
655
|
+
throw new S3Error(versionId ? 'NoSuchVersion' : 'NoSuchKey', 'Object not found', 404, bucket, key);
|
|
656
|
+
await this.run('INSERT OR REPLACE INTO object_tags (bucket, key, versionId, tags) VALUES (?, ?, ?, ?)', [bucket, key, versionId, JSON.stringify(tags)]);
|
|
657
|
+
}
|
|
658
|
+
async getObjectTags(bucket, key, versionId = '') {
|
|
659
|
+
await this.headBucket(bucket);
|
|
660
|
+
const row = await this.get('SELECT tags FROM object_tags WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
661
|
+
if (!row)
|
|
662
|
+
throw new S3Error('NoSuchTagSet', 'The object has no tags.', 404, bucket, key);
|
|
663
|
+
return JSON.parse(row.tags);
|
|
664
|
+
}
|
|
665
|
+
async deleteObjectTags(bucket, key, versionId = '') {
|
|
666
|
+
await this.headBucket(bucket);
|
|
667
|
+
await this.run('DELETE FROM object_tags WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
668
|
+
}
|
|
669
|
+
async putObjectAcl(bucket, key, acl, versionId = '') {
|
|
670
|
+
await this.headBucket(bucket);
|
|
671
|
+
await this.getObject(bucket, key, versionId || undefined);
|
|
672
|
+
await this.run('INSERT OR REPLACE INTO object_acl (bucket, key, versionId, acl) VALUES (?, ?, ?, ?)', [bucket, key, versionId, JSON.stringify(acl)]);
|
|
673
|
+
}
|
|
674
|
+
async getObjectAcl(bucket, key, versionId = '') {
|
|
675
|
+
await this.headBucket(bucket);
|
|
676
|
+
await this.getObject(bucket, key, versionId || undefined);
|
|
677
|
+
const row = await this.get('SELECT acl FROM object_acl WHERE bucket = ? AND key = ? AND versionId = ?', [bucket, key, versionId]);
|
|
678
|
+
return (row ? JSON.parse(row.acl) : { CannedACL: 'private' });
|
|
679
|
+
}
|
|
680
|
+
async putBucketTags(bucket, tags) {
|
|
681
|
+
await this.headBucket(bucket);
|
|
682
|
+
await this.run('UPDATE bucket_settings SET bucketTags = ? WHERE bucket = ?', [JSON.stringify(tags), bucket]);
|
|
683
|
+
}
|
|
684
|
+
async getBucketTags(bucket) {
|
|
685
|
+
await this.headBucket(bucket);
|
|
686
|
+
const row = await this.get('SELECT bucketTags FROM bucket_settings WHERE bucket = ?', [bucket]);
|
|
687
|
+
return row?.bucketTags ? JSON.parse(row.bucketTags) : {};
|
|
688
|
+
}
|
|
689
|
+
async deleteBucketTags(bucket) {
|
|
690
|
+
await this.headBucket(bucket);
|
|
691
|
+
await this.run('UPDATE bucket_settings SET bucketTags = NULL WHERE bucket = ?', [bucket]);
|
|
692
|
+
}
|
|
693
|
+
async getBucketLocation(bucket) {
|
|
694
|
+
const row = await this.get('SELECT locationConstraint FROM buckets WHERE name = ?', [bucket]);
|
|
695
|
+
if (!row)
|
|
696
|
+
throw new S3Error('NoSuchBucket', 'Bucket not found', 404, bucket);
|
|
697
|
+
return row.locationConstraint;
|
|
698
|
+
}
|
|
699
|
+
async putBucketConfiguration(bucket, name, value) {
|
|
700
|
+
await this.headBucket(bucket);
|
|
701
|
+
await this.run(`UPDATE bucket_settings SET ${name} = ? WHERE bucket = ?`, [JSON.stringify(value), bucket]);
|
|
702
|
+
}
|
|
703
|
+
async getBucketConfiguration(bucket, name) {
|
|
704
|
+
await this.headBucket(bucket);
|
|
705
|
+
const row = await this.get(`SELECT ${name} FROM bucket_settings WHERE bucket = ?`, [bucket]);
|
|
706
|
+
return row?.[name] ? JSON.parse(row[name]) : undefined;
|
|
707
|
+
}
|
|
708
|
+
async deleteBucketConfiguration(bucket, name) {
|
|
709
|
+
await this.headBucket(bucket);
|
|
710
|
+
await this.run(`UPDATE bucket_settings SET ${name} = NULL WHERE bucket = ?`, [bucket]);
|
|
711
|
+
}
|
|
712
|
+
async isRequestDenied(bucket, key, action, principal = '*', context = {}) {
|
|
713
|
+
const policy = await this.getBucketConfiguration(bucket, 'policy');
|
|
714
|
+
if (!policy?.statements)
|
|
715
|
+
return false;
|
|
716
|
+
const resource = `arn:aws:s3:::${bucket}${key ? `/${key}` : ''}`;
|
|
717
|
+
const matching = policy.statements.filter(statement => policyStatementMatches(statement, resource, action, principal, context));
|
|
718
|
+
if (matching.some(statement => statement.effect === 'Deny'))
|
|
719
|
+
return true;
|
|
720
|
+
return matching.some(statement => statement.effect === 'Allow') ? false : policy.statements.some(statement => statement.effect === 'Allow');
|
|
721
|
+
}
|
|
722
|
+
async isObjectRequestDenied(bucket, key, action, principal) {
|
|
723
|
+
const acl = await this.getObjectAcl(bucket, key).catch(() => ({ CannedACL: 'private' }));
|
|
724
|
+
if (principal !== 'anonymous')
|
|
725
|
+
return false;
|
|
726
|
+
const requiredPermission = action === 'GetObject' ? 'READ' : action === 'GetObjectAcl' ? 'READ_ACP' : action === 'PutObjectAcl' ? 'WRITE_ACP' : 'WRITE';
|
|
727
|
+
if (acl.CannedACL) {
|
|
728
|
+
if (acl.CannedACL === 'public-read')
|
|
729
|
+
return requiredPermission === 'READ';
|
|
730
|
+
if (acl.CannedACL === 'public-read-write')
|
|
731
|
+
return requiredPermission === 'READ' || requiredPermission === 'WRITE';
|
|
732
|
+
if (acl.CannedACL === 'authenticated-read')
|
|
733
|
+
return !(principal !== 'anonymous' && requiredPermission === 'READ');
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
const grants = Array.isArray(acl.Grants) ? acl.Grants : [];
|
|
737
|
+
return !grants.some((grant) => {
|
|
738
|
+
const grantee = grant.Grantee || {};
|
|
739
|
+
const matches = grantee.ID === principal || (principal === 'anonymous' && grantee.URI === 'http://acs.amazonaws.com/groups/global/AllUsers') || (principal !== 'anonymous' && grantee.URI === 'http://acs.amazonaws.com/groups/global/AuthenticatedUsers');
|
|
740
|
+
return matches && grant.Permission === requiredPermission;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
async listObjectsV2(bucketName, prefix) {
|
|
744
|
+
const rows = await this.all('SELECT * FROM objs WHERE bucket = ?', [bucketName]);
|
|
745
|
+
const filtered = rows.filter((r) => !prefix || r.key.startsWith(prefix));
|
|
746
|
+
return filtered.map((r) => ({
|
|
747
|
+
Key: r.key, LastModified: new Date(r.lastModified), ETag: r.etag,
|
|
748
|
+
Size: r.size, StorageClass: r.storageClass, Owner: { ID: r.ownerId, DisplayName: r.ownerDisplayName }
|
|
749
|
+
}));
|
|
750
|
+
}
|
|
751
|
+
async listObjectsV2Advanced(request) {
|
|
752
|
+
await this.headBucket(request.bucket);
|
|
753
|
+
await this.applyLifecycle(request.bucket);
|
|
754
|
+
const rows = await this.all('SELECT * FROM objs WHERE bucket = ? ORDER BY key ASC', [request.bucket]);
|
|
755
|
+
const prefix = request.prefix || '';
|
|
756
|
+
const delimiter = request.delimiter;
|
|
757
|
+
const marker = request.continuationToken
|
|
758
|
+
? Buffer.from(request.continuationToken, 'base64url').toString('utf8')
|
|
759
|
+
: request.startAfter;
|
|
760
|
+
const entries = new Map();
|
|
761
|
+
for (const row of rows) {
|
|
762
|
+
if (!row.key.startsWith(prefix) || (marker && row.key <= marker))
|
|
763
|
+
continue;
|
|
764
|
+
if (delimiter) {
|
|
765
|
+
const remainder = row.key.slice(prefix.length);
|
|
766
|
+
const delimiterIndex = remainder.indexOf(delimiter);
|
|
767
|
+
if (delimiterIndex >= 0) {
|
|
768
|
+
const commonPrefix = prefix + remainder.slice(0, delimiterIndex + delimiter.length);
|
|
769
|
+
entries.set(commonPrefix, commonPrefix);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
entries.set(row.key, {
|
|
774
|
+
key: row.key,
|
|
775
|
+
etag: row.etag,
|
|
776
|
+
size: row.size,
|
|
777
|
+
lastModified: new Date(row.lastModified),
|
|
778
|
+
storageClass: row.storageClass,
|
|
779
|
+
owner: { id: row.ownerId, displayName: row.ownerDisplayName },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
const ordered = [...entries.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
783
|
+
const maxKeys = Math.max(0, Math.min(request.maxKeys ?? 1000, 1000));
|
|
784
|
+
const page = ordered.slice(0, maxKeys);
|
|
785
|
+
const isTruncated = ordered.length > page.length;
|
|
786
|
+
const lastEntry = page[page.length - 1]?.[0];
|
|
787
|
+
const result = {
|
|
788
|
+
name: request.bucket,
|
|
789
|
+
prefix: request.prefix,
|
|
790
|
+
delimiter: request.delimiter,
|
|
791
|
+
maxKeys,
|
|
792
|
+
keyCount: page.length,
|
|
793
|
+
isTruncated,
|
|
794
|
+
contents: page.flatMap(([, value]) => typeof value === 'string' ? [] : [value]),
|
|
795
|
+
commonPrefixes: page.flatMap(([, value]) => typeof value === 'string' ? [value] : []),
|
|
796
|
+
startAfter: request.startAfter,
|
|
797
|
+
encodingType: request.encodingType,
|
|
798
|
+
};
|
|
799
|
+
if (request.continuationToken)
|
|
800
|
+
result.continuationToken = request.continuationToken;
|
|
801
|
+
if (isTruncated && lastEntry)
|
|
802
|
+
result.nextContinuationToken = Buffer.from(lastEntry).toString('base64url');
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
async createMultipartUpload(bucket, key, storageClass = 'STANDARD') {
|
|
806
|
+
await this.headBucket(bucket);
|
|
807
|
+
const uploadId = crypto.randomUUID();
|
|
808
|
+
await this.run('INSERT INTO multipart_uploads (uploadId, bucket, key, initiated, storageClass) VALUES (?, ?, ?, ?, ?)', [uploadId, bucket, key, Date.now(), storageClass]);
|
|
809
|
+
return { uploadId, bucket, key };
|
|
810
|
+
}
|
|
811
|
+
async uploadPart(request) {
|
|
812
|
+
const upload = await this.get('SELECT uploadId FROM multipart_uploads WHERE uploadId = ? AND bucket = ? AND key = ?', [request.uploadId, request.bucket, request.key]);
|
|
813
|
+
if (!upload)
|
|
814
|
+
throw new S3Error('NoSuchUpload', 'The specified multipart upload does not exist.', 404, request.bucket, request.key);
|
|
815
|
+
if (request.partNumber < 1 || request.partNumber > 10000)
|
|
816
|
+
throw new S3Error('InvalidPart', 'Part number must be between 1 and 10000.', 400, request.bucket, request.key);
|
|
817
|
+
const etag = '"' + crypto.createHash('md5').update(request.body).digest('hex') + '"';
|
|
818
|
+
const lastModified = Date.now();
|
|
819
|
+
await this.run('INSERT OR REPLACE INTO multipart_parts (uploadId, partNumber, etag, size, lastModified, body) VALUES (?, ?, ?, ?, ?, ?)', [request.uploadId, request.partNumber, etag, request.body.length, lastModified, request.body]);
|
|
820
|
+
return { partNumber: request.partNumber, etag, size: request.body.length, lastModified: new Date(lastModified) };
|
|
821
|
+
}
|
|
822
|
+
async uploadPartCopy(bucket, key, uploadId, partNumber, sourceBucket, sourceKey) {
|
|
823
|
+
const source = await this.getObject(sourceBucket, sourceKey);
|
|
824
|
+
return this.uploadPart({ bucket, key, uploadId, partNumber, body: source.data });
|
|
825
|
+
}
|
|
826
|
+
async listParts(request) {
|
|
827
|
+
const upload = await this.get('SELECT uploadId FROM multipart_uploads WHERE uploadId = ? AND bucket = ? AND key = ?', [request.uploadId, request.bucket, request.key]);
|
|
828
|
+
if (!upload)
|
|
829
|
+
throw new S3Error('NoSuchUpload', 'The specified multipart upload does not exist.', 404, request.bucket, request.key);
|
|
830
|
+
const rows = await this.all('SELECT * FROM multipart_parts WHERE uploadId = ? AND partNumber > ? ORDER BY partNumber ASC', [request.uploadId, request.partNumberMarker || 0]);
|
|
831
|
+
const maxParts = Math.max(0, Math.min(request.maxParts ?? 1000, 1000));
|
|
832
|
+
const page = rows.slice(0, maxParts);
|
|
833
|
+
return {
|
|
834
|
+
bucket: request.bucket,
|
|
835
|
+
key: request.key,
|
|
836
|
+
uploadId: request.uploadId,
|
|
837
|
+
parts: page.map(row => ({ partNumber: row.partNumber, etag: row.etag, size: row.size, lastModified: new Date(row.lastModified) })),
|
|
838
|
+
isTruncated: rows.length > page.length,
|
|
839
|
+
nextPartNumberMarker: rows.length > page.length ? page[page.length - 1].partNumber : undefined,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
async completeMultipartUpload(request) {
|
|
843
|
+
const upload = await this.get('SELECT * FROM multipart_uploads WHERE uploadId = ? AND bucket = ? AND key = ?', [request.uploadId, request.bucket, request.key]);
|
|
844
|
+
if (!upload)
|
|
845
|
+
throw new S3Error('NoSuchUpload', 'The specified multipart upload does not exist.', 404, request.bucket, request.key);
|
|
846
|
+
if (!request.parts.length)
|
|
847
|
+
throw new S3Error('InvalidRequest', 'At least one part is required.', 400, request.bucket, request.key);
|
|
848
|
+
const bodies = [];
|
|
849
|
+
for (const part of request.parts) {
|
|
850
|
+
const row = await this.get('SELECT body, etag FROM multipart_parts WHERE uploadId = ? AND partNumber = ?', [request.uploadId, part.partNumber]);
|
|
851
|
+
if (!row || row.etag !== part.etag)
|
|
852
|
+
throw new S3Error('InvalidPart', 'One or more requested parts could not be found.', 400, request.bucket, request.key);
|
|
853
|
+
bodies.push(row.body);
|
|
854
|
+
}
|
|
855
|
+
const result = await this.putObject(request.bucket, request.key, Buffer.concat(bodies), {});
|
|
856
|
+
await this.run('DELETE FROM multipart_parts WHERE uploadId = ?', [request.uploadId]);
|
|
857
|
+
await this.run('DELETE FROM multipart_uploads WHERE uploadId = ?', [request.uploadId]);
|
|
858
|
+
return { bucket: request.bucket, key: request.key, etag: result.etag, versionId: result.versionId };
|
|
859
|
+
}
|
|
860
|
+
async abortMultipartUpload(bucket, key, uploadId) {
|
|
861
|
+
const upload = await this.get('SELECT uploadId FROM multipart_uploads WHERE uploadId = ? AND bucket = ? AND key = ?', [uploadId, bucket, key]);
|
|
862
|
+
if (!upload)
|
|
863
|
+
throw new S3Error('NoSuchUpload', 'The specified multipart upload does not exist.', 404, bucket, key);
|
|
864
|
+
await this.run('DELETE FROM multipart_parts WHERE uploadId = ?', [uploadId]);
|
|
865
|
+
await this.run('DELETE FROM multipart_uploads WHERE uploadId = ?', [uploadId]);
|
|
866
|
+
}
|
|
867
|
+
async listMultipartUploads(request) {
|
|
868
|
+
await this.headBucket(request.bucket);
|
|
869
|
+
const rows = await this.all('SELECT * FROM multipart_uploads WHERE bucket = ? ORDER BY key, initiated', [request.bucket]);
|
|
870
|
+
const filtered = rows.filter(row => !request.prefix || row.key.startsWith(request.prefix));
|
|
871
|
+
return {
|
|
872
|
+
bucket: request.bucket,
|
|
873
|
+
uploads: filtered.slice(0, request.maxUploads ?? 1000).map(row => ({
|
|
874
|
+
uploadId: row.uploadId,
|
|
875
|
+
bucket: row.bucket,
|
|
876
|
+
key: row.key,
|
|
877
|
+
initiated: new Date(row.initiated),
|
|
878
|
+
initiator: { id: '000000000000000000000000', displayName: 's3mini' },
|
|
879
|
+
owner: { id: '000000000000000000000000', displayName: 's3mini' },
|
|
880
|
+
storageClass: row.storageClass,
|
|
881
|
+
parts: [],
|
|
882
|
+
})),
|
|
883
|
+
commonPrefixes: [],
|
|
884
|
+
isTruncated: filtered.length > (request.maxUploads ?? 1000),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async function writeDurableFile(filePath, body) {
|
|
889
|
+
const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
890
|
+
try {
|
|
891
|
+
const handle = await fs.open(temporaryPath, 'w');
|
|
892
|
+
try {
|
|
893
|
+
await handle.writeFile(body);
|
|
894
|
+
await handle.sync();
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
await handle.close();
|
|
898
|
+
}
|
|
899
|
+
await fs.rename(temporaryPath, filePath);
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
await fs.rm(temporaryPath, { force: true });
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function lifecycleRuleMatches(rule, key) {
|
|
907
|
+
if (rule.status && rule.status !== 'Enabled')
|
|
908
|
+
return false;
|
|
909
|
+
const prefix = rule.filter?.prefix ?? rule.prefix ?? '';
|
|
910
|
+
return !prefix || key.startsWith(prefix);
|
|
911
|
+
}
|
|
912
|
+
function lifecycleAgeReached(condition, lastModified, now) {
|
|
913
|
+
if (condition.date !== undefined)
|
|
914
|
+
return now >= Date.parse(String(condition.date));
|
|
915
|
+
const days = condition.days ?? condition.noncurrentDays;
|
|
916
|
+
if (days !== undefined)
|
|
917
|
+
return now >= lastModified + Number(days) * 86_400_000;
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
function lifecycleTransition(rule, noncurrent, lastModified, now) {
|
|
921
|
+
const transitions = noncurrent ? rule.noncurrentVersionTransitions : rule.transitions;
|
|
922
|
+
if (!Array.isArray(transitions))
|
|
923
|
+
return undefined;
|
|
924
|
+
return transitions
|
|
925
|
+
.filter(transition => lifecycleAgeReached(transition, lastModified, now))
|
|
926
|
+
.sort((left, right) => Number(right.days ?? right.noncurrentDays ?? 0) - Number(left.days ?? left.noncurrentDays ?? 0))
|
|
927
|
+
.map(transition => transition.storageClass || transition.StorageClass)
|
|
928
|
+
.find(Boolean);
|
|
929
|
+
}
|
|
930
|
+
function policyStatementMatches(statement, resource, action, principal, context) {
|
|
931
|
+
const actions = Array.isArray(statement.action) ? statement.action : [statement.action];
|
|
932
|
+
const resources = Array.isArray(statement.resource) ? statement.resource : [statement.resource];
|
|
933
|
+
const principals = statement.principal === '*' || statement.principal?.aws === '*' || [statement.principal, statement.principal?.aws].includes(principal);
|
|
934
|
+
const actionMatches = actions.some((value) => typeof value === 'string' && wildcardMatches(value, action));
|
|
935
|
+
const resourceMatches = resources.some((value) => typeof value === 'string' && wildcardMatches(value, resource));
|
|
936
|
+
if (!principals || !actionMatches || !resourceMatches)
|
|
937
|
+
return false;
|
|
938
|
+
return policyConditionsMatch(statement.condition, context);
|
|
939
|
+
}
|
|
940
|
+
function policyConditionsMatch(condition, context) {
|
|
941
|
+
if (!condition)
|
|
942
|
+
return true;
|
|
943
|
+
for (const [operator, entries] of Object.entries(condition)) {
|
|
944
|
+
for (const [name, expectedValue] of Object.entries(entries || {})) {
|
|
945
|
+
const actual = context[name];
|
|
946
|
+
const expected = Array.isArray(expectedValue) ? expectedValue.map(String) : [String(expectedValue)];
|
|
947
|
+
const matches = operator === 'StringLike'
|
|
948
|
+
? expected.some(value => actual !== undefined && wildcardMatches(value, actual))
|
|
949
|
+
: expected.includes(actual || '');
|
|
950
|
+
if (operator === 'StringNotEquals' ? expected.includes(actual || '') : !matches)
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
function wildcardMatches(pattern, value) {
|
|
957
|
+
const expression = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
|
|
958
|
+
return new RegExp(expression).test(value);
|
|
959
|
+
}
|
|
960
|
+
//# sourceMappingURL=s3mini.js.map
|