@felipedsvit/s3node 0.1.2
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 +143 -0
- package/bin/s3node.js +101 -0
- package/docs/plan.md +340 -0
- package/package.json +50 -0
- package/src/auth/chunked.js +277 -0
- package/src/auth/credentials.js +41 -0
- package/src/auth/sigv4.js +293 -0
- package/src/errors.js +65 -0
- package/src/features/cors.js +115 -0
- package/src/features/encryption.js +201 -0
- package/src/features/formdata.js +169 -0
- package/src/features/lifecycle.js +175 -0
- package/src/features/notifications.js +161 -0
- package/src/features/policy.js +0 -0
- package/src/features/postpolicy.js +150 -0
- package/src/features/tagging.js +64 -0
- package/src/handlers/bucket.js +197 -0
- package/src/handlers/config.js +129 -0
- package/src/handlers/index.js +6 -0
- package/src/handlers/multipart.js +109 -0
- package/src/handlers/object.js +211 -0
- package/src/handlers/post.js +106 -0
- package/src/handlers/service.js +10 -0
- package/src/handlers/shared.js +84 -0
- package/src/http.js +243 -0
- package/src/index.js +30 -0
- package/src/router.js +128 -0
- package/src/server.js +282 -0
- package/src/storage/blobs.js +135 -0
- package/src/storage/metadata.js +616 -0
- package/src/storage/store.js +548 -0
- package/src/util/bytes.js +35 -0
- package/src/util/crc.js +54 -0
- package/src/util/hash.js +64 -0
- package/src/xml.js +205 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs'
|
|
3
|
+
import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { Readable } from 'node:stream'
|
|
6
|
+
import { pipeline } from 'node:stream/promises'
|
|
7
|
+
import { HashingStream } from '../util/hash.js'
|
|
8
|
+
|
|
9
|
+
const READ_HIGH_WATER_MARK = 1024 * 1024
|
|
10
|
+
|
|
11
|
+
async function fsyncDirectory(directory) {
|
|
12
|
+
let handle
|
|
13
|
+
try {
|
|
14
|
+
handle = await open(directory, 'r')
|
|
15
|
+
await handle.sync()
|
|
16
|
+
} catch {
|
|
17
|
+
// Directory fsync is unavailable on some platforms (notably Windows).
|
|
18
|
+
} finally {
|
|
19
|
+
await handle?.close().catch(() => {})
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Content-addressed blob storage.
|
|
25
|
+
*
|
|
26
|
+
* Blob names are decoupled from object keys. That is what makes `../` in a key
|
|
27
|
+
* harmless: a key never becomes a filesystem path, so the entire path-traversal
|
|
28
|
+
* class is eliminated by construction (docs/plan.md section 7).
|
|
29
|
+
*/
|
|
30
|
+
export class BlobStore {
|
|
31
|
+
constructor(root) {
|
|
32
|
+
this.root = root
|
|
33
|
+
this.dataDir = join(root, 'data')
|
|
34
|
+
this.tmpDir = join(root, 'tmp')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async init() {
|
|
38
|
+
await mkdir(this.dataDir, { recursive: true })
|
|
39
|
+
await mkdir(this.tmpDir, { recursive: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Two-level fanout keeps directory sizes bounded. */
|
|
43
|
+
path(blobId) {
|
|
44
|
+
return join(this.dataDir, blobId.slice(0, 2), blobId.slice(2, 4), blobId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Durable write. The step order matters and is load-bearing: the blob is
|
|
49
|
+
* fully persisted before any metadata references it. A crash mid-sequence
|
|
50
|
+
* leaves an orphan blob (swept later); the reverse order would leave metadata
|
|
51
|
+
* pointing at a missing blob, which is observable data loss.
|
|
52
|
+
*/
|
|
53
|
+
async write(source, { algorithms = ['md5'], transforms = [] } = {}) {
|
|
54
|
+
const blobId = randomUUID().replaceAll('-', '')
|
|
55
|
+
const tmpPath = join(this.tmpDir, blobId)
|
|
56
|
+
const hasher = new HashingStream(algorithms)
|
|
57
|
+
// `source` may be a chain (request -> chunked decoder); pipeline propagates
|
|
58
|
+
// errors and destroys every stage, which a bare .pipe() would not.
|
|
59
|
+
const chain = Array.isArray(source) ? source : [source]
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// 1-2. Stream to a temp file on the same filesystem, hashing in one pass.
|
|
63
|
+
// Digests are taken over the plaintext, so any encryption transform
|
|
64
|
+
// sits after the hasher — the ETag stays the MD5 clients expect.
|
|
65
|
+
await pipeline(...chain, hasher, ...transforms,
|
|
66
|
+
createWriteStream(tmpPath, { flags: 'wx', mode: 0o600 }))
|
|
67
|
+
|
|
68
|
+
// 3. fsync the file itself.
|
|
69
|
+
const handle = await open(tmpPath, 'r+')
|
|
70
|
+
try {
|
|
71
|
+
await handle.sync()
|
|
72
|
+
} finally {
|
|
73
|
+
await handle.close()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4-5. Atomically move into place, then fsync the parent directory so the
|
|
77
|
+
// rename survives power loss.
|
|
78
|
+
const finalPath = this.path(blobId)
|
|
79
|
+
await mkdir(dirname(finalPath), { recursive: true })
|
|
80
|
+
await rename(tmpPath, finalPath)
|
|
81
|
+
await fsyncDirectory(dirname(finalPath))
|
|
82
|
+
|
|
83
|
+
return { blobId, size: hasher.bytesWritten, hasher }
|
|
84
|
+
} catch (err) {
|
|
85
|
+
await rm(tmpPath, { force: true }).catch(() => {})
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async remove(blobId) {
|
|
91
|
+
if (!blobId) return
|
|
92
|
+
await rm(this.path(blobId), { force: true }).catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async removeMany(blobIds) {
|
|
96
|
+
await Promise.all([...new Set(blobIds)].map((id) => this.remove(id)))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async size(blobId) {
|
|
100
|
+
return (await stat(this.path(blobId))).size
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
createReadStream(blobId, { start, end } = {}) {
|
|
104
|
+
return createReadStream(this.path(blobId), {
|
|
105
|
+
start,
|
|
106
|
+
end,
|
|
107
|
+
highWaterMark: READ_HIGH_WATER_MARK,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Virtual concatenation across a multipart manifest. Parts stay as separate
|
|
113
|
+
* blobs — `CompleteMultipartUpload` writes a manifest instead of rewriting
|
|
114
|
+
* the object, which keeps Complete cheap and range reads efficient
|
|
115
|
+
* (docs/plan.md section 8).
|
|
116
|
+
*/
|
|
117
|
+
createRangeStream(parts, start, end) {
|
|
118
|
+
const store = this
|
|
119
|
+
async function* generate() {
|
|
120
|
+
let offset = 0
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
const partStart = offset
|
|
123
|
+
const partEnd = offset + part.size - 1
|
|
124
|
+
offset += part.size
|
|
125
|
+
if (partEnd < start) continue
|
|
126
|
+
if (partStart > end) break
|
|
127
|
+
const from = Math.max(start - partStart, 0)
|
|
128
|
+
const to = Math.min(end - partStart, part.size - 1)
|
|
129
|
+
if (to < from) continue
|
|
130
|
+
yield* store.createReadStream(part.blobId, { start: from, end: to })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return Readable.from(generate(), { highWaterMark: READ_HIGH_WATER_MARK })
|
|
134
|
+
}
|
|
135
|
+
}
|