@mostajs/blob-store 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/drivers/s3.d.ts +51 -0
- package/dist/drivers/s3.js +219 -0
- package/dist/drivers/sigv4.d.ts +55 -0
- package/dist/drivers/sigv4.js +110 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +9 -1
- package/llms.txt +10 -2
- package/package.json +16 -3
package/README.md
CHANGED
|
@@ -50,14 +50,40 @@ Erreurs **typées** (jamais nues) : `StorageError` avec un `code` (`not_found`,
|
|
|
50
50
|
- **`FilesystemDriver`** — disque local. Sanitise les chemins (le `..` est refusé : un driver
|
|
51
51
|
de stockage est une surface d'attaque).
|
|
52
52
|
- **`CachingDriver`** — décorateur de cache devant un driver d'origine.
|
|
53
|
-
-
|
|
54
|
-
|
|
53
|
+
- **`S3Driver`** — S3 **et tout service S3-compatible** : AWS, MinIO, Scaleway, Wasabi,
|
|
54
|
+
Backblaze… **Zéro dépendance** : SigV4 est implémenté ici (S3 n'est que du HTTP signé), au
|
|
55
|
+
lieu de tirer `@aws-sdk/client-s3` et ses dizaines de mégaoctets. Pour héberger les
|
|
56
|
+
sauvegardes de vos clients sur vos propres serveurs, cette souveraineté n'est pas un détail.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { S3Driver } from '@mostajs/blob-store/drivers/s3';
|
|
60
|
+
|
|
61
|
+
const s3 = new S3Driver({
|
|
62
|
+
endpoint: 'http://localhost:9000', // MinIO auto-hébergé, ou https://s3.eu-west-3.amazonaws.com
|
|
63
|
+
region: 'eu-west-3',
|
|
64
|
+
accessKeyId: process.env.S3_KEY,
|
|
65
|
+
secretAccessKey: process.env.S3_SECRET,
|
|
66
|
+
forcePathStyle: true, // requis par la plupart des services auto-hébergés
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Dette soldée** : `@mostajs/storage` annonçait « bytes in object storage (FS / **S3**) »
|
|
71
|
+
depuis sa 0.2.0 — sans qu'aucun pilote S3 n'existe. La promesse est tenue **ici**, au niveau
|
|
72
|
+
de la primitive, pour qu'`archive-box` et `backup` en profitent aussi. La poser dans
|
|
73
|
+
`storage` aurait payé la dette au mauvais étage.
|
|
55
74
|
|
|
56
75
|
## Tests
|
|
57
76
|
|
|
58
77
|
```bash
|
|
59
|
-
npm test #
|
|
78
|
+
npm test # 28 tests mjs-unit : disque réel, SigV4, serveur S3 simulé
|
|
60
79
|
```
|
|
61
80
|
|
|
62
|
-
Un test de stockage qui n'écrit rien ne prouve rien :
|
|
63
|
-
|
|
81
|
+
Un test de stockage qui n'écrit rien ne prouve rien :
|
|
82
|
+
|
|
83
|
+
- **Filesystem** — disque réel, vecteur SHA-256 **vérifié indépendamment** (et non recopié
|
|
84
|
+
depuis le pilote lui-même).
|
|
85
|
+
- **SigV4** — validée contre les **vecteurs officiels d'AWS** (dérivation de clé, requête
|
|
86
|
+
canonique et signature de la suite « get-vanilla »). Une signature fausse ne se manifesterait
|
|
87
|
+
qu'à l'exécution, par un `403 SignatureDoesNotMatch` indéchiffrable sur un serveur distant.
|
|
88
|
+
- **S3** — serveur S3 **simulé** qui **refuse toute requête non signée** : un pilote qui
|
|
89
|
+
« marcherait » sans `Authorization` passerait des tests naïfs, puis échouerait chez le client.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult } from '../driver.js';
|
|
2
|
+
import type { ObjectRef, PutObjectResult } from '../types.js';
|
|
3
|
+
import { type SigV4Credentials } from './sigv4.js';
|
|
4
|
+
export interface S3DriverConfig extends SigV4Credentials {
|
|
5
|
+
/** Ex. `https://s3.eu-west-3.amazonaws.com` ou `http://localhost:9000` (MinIO). */
|
|
6
|
+
endpoint: string;
|
|
7
|
+
region: string;
|
|
8
|
+
/**
|
|
9
|
+
* `true` (défaut) → `https://host/bucket/cle` ; `false` → `https://bucket.host/cle`.
|
|
10
|
+
* Les services auto-hébergés (MinIO) exigent généralement le style « path ».
|
|
11
|
+
*/
|
|
12
|
+
forcePathStyle?: boolean;
|
|
13
|
+
/** Timeout par requête (ms). Défaut 30 s — une requête sans borne fige l'appelant. */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class S3Driver implements StorageDriver {
|
|
17
|
+
private config;
|
|
18
|
+
readonly id = "s3";
|
|
19
|
+
constructor(config: S3DriverConfig);
|
|
20
|
+
/** URL d'un objet (ou du bucket si `key` est vide), selon le style d'adressage. */
|
|
21
|
+
private url;
|
|
22
|
+
/** Envoie une requête signée. Traduit les statuts HTTP en `StorageError` typées. */
|
|
23
|
+
private send;
|
|
24
|
+
/**
|
|
25
|
+
* Écrit un objet.
|
|
26
|
+
*
|
|
27
|
+
* Le corps est mis EN MÉMOIRE avant envoi. C'est un choix assumé : SigV4 exige soit le
|
|
28
|
+
* hash sha256 de la charge utile, soit la signature par blocs (« chunked »), nettement plus
|
|
29
|
+
* complexe. Bufferiser donne un `checksum` fiable et un `Content-Length` exact — au prix de
|
|
30
|
+
* la mémoire. Pour de très gros objets, ce pilote appellera un jour l'upload multipart ;
|
|
31
|
+
* mieux vaut une limite CONNUE qu'un streaming subtilement faux.
|
|
32
|
+
*/
|
|
33
|
+
put(ref: ObjectRef, body: PutBody, opts: PutOptions): Promise<PutObjectResult>;
|
|
34
|
+
get(ref: ObjectRef): Promise<GetObjectResult>;
|
|
35
|
+
delete(ref: ObjectRef): Promise<void>;
|
|
36
|
+
exists(ref: ObjectRef): Promise<boolean>;
|
|
37
|
+
stat(ref: ObjectRef): Promise<StatResult>;
|
|
38
|
+
list(args: {
|
|
39
|
+
bucket: string;
|
|
40
|
+
prefix?: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
cursor?: string;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
items: {
|
|
45
|
+
path: string;
|
|
46
|
+
size: number;
|
|
47
|
+
modifiedAt: Date;
|
|
48
|
+
}[];
|
|
49
|
+
nextCursor?: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @mostajs/blob-store — Pilote S3 (et tout service S3-compatible)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
// License: AGPL-3.0-or-later
|
|
4
|
+
//
|
|
5
|
+
// ZÉRO DÉPENDANCE : S3 n'est que du HTTP signé (SigV4). On n'embarque pas
|
|
6
|
+
// `@aws-sdk/client-s3` — des dizaines de Mo de dépendances pour poser et relire des octets.
|
|
7
|
+
//
|
|
8
|
+
// Conséquence heureuse : le pilote parle à TOUT service S3-compatible — AWS, mais aussi
|
|
9
|
+
// MinIO, Scaleway, Wasabi, Backblaze, ou un MinIO auto-hébergé. Pour des sauvegardes
|
|
10
|
+
// clients, cette souveraineté n'est pas un détail.
|
|
11
|
+
//
|
|
12
|
+
// DETTE SOLDÉE : `@mostajs/storage` annonçait « bytes in object storage (FS / S3) » depuis
|
|
13
|
+
// sa 0.2.0 — sans qu'aucun pilote S3 n'existe. La promesse est tenue ICI, au niveau de la
|
|
14
|
+
// primitive, pour que `@mostajs/archive-box` et `@mostajs/backup` en profitent aussi. La
|
|
15
|
+
// poser dans `storage` aurait payé la dette au mauvais étage.
|
|
16
|
+
import { StorageError } from '../types.js';
|
|
17
|
+
import { signRequest, hashPayload, uriEncode } from './sigv4.js';
|
|
18
|
+
/** Lit entièrement un corps en mémoire (voir la note sur le buffering dans `put`). */
|
|
19
|
+
async function toBytes(body) {
|
|
20
|
+
if (body instanceof Uint8Array)
|
|
21
|
+
return body;
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of body) {
|
|
24
|
+
chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
|
25
|
+
}
|
|
26
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
27
|
+
const out = new Uint8Array(total);
|
|
28
|
+
let off = 0;
|
|
29
|
+
for (const c of chunks) {
|
|
30
|
+
out.set(c, off);
|
|
31
|
+
off += c.length;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Extrait le contenu des balises `<tag>…</tag>` — suffisant pour le XML plat de S3. */
|
|
36
|
+
function xmlAll(xml, tag) {
|
|
37
|
+
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g');
|
|
38
|
+
const out = [];
|
|
39
|
+
let m;
|
|
40
|
+
while ((m = re.exec(xml)) !== null)
|
|
41
|
+
out.push(m[1]);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const xmlOne = (xml, tag) => xmlAll(xml, tag)[0];
|
|
45
|
+
export class S3Driver {
|
|
46
|
+
config;
|
|
47
|
+
id = 's3';
|
|
48
|
+
constructor(config) {
|
|
49
|
+
this.config = config;
|
|
50
|
+
if (!config.endpoint)
|
|
51
|
+
throw new StorageError('driver_error', 'S3Driver: endpoint requis');
|
|
52
|
+
if (!config.region)
|
|
53
|
+
throw new StorageError('driver_error', 'S3Driver: region requise');
|
|
54
|
+
}
|
|
55
|
+
/** URL d'un objet (ou du bucket si `key` est vide), selon le style d'adressage. */
|
|
56
|
+
url(bucket, key = '') {
|
|
57
|
+
const base = new URL(this.config.endpoint);
|
|
58
|
+
const pathStyle = this.config.forcePathStyle !== false;
|
|
59
|
+
const encodedKey = key ? uriEncode(key, false) : '';
|
|
60
|
+
const host = pathStyle ? base.host : `${bucket}.${base.host}`;
|
|
61
|
+
const path = pathStyle
|
|
62
|
+
? `/${uriEncode(bucket, false)}${encodedKey ? `/${encodedKey}` : ''}`
|
|
63
|
+
: `/${encodedKey}`;
|
|
64
|
+
return { href: `${base.protocol}//${host}${path}`, host, path };
|
|
65
|
+
}
|
|
66
|
+
/** Envoie une requête signée. Traduit les statuts HTTP en `StorageError` typées. */
|
|
67
|
+
async send(args) {
|
|
68
|
+
const { href, host, path } = this.url(args.bucket, args.key);
|
|
69
|
+
const payloadHash = args.payloadHash ?? hashPayload(args.body);
|
|
70
|
+
const { headers } = signRequest({
|
|
71
|
+
req: {
|
|
72
|
+
method: args.method,
|
|
73
|
+
path,
|
|
74
|
+
query: args.query,
|
|
75
|
+
headers: { host, ...(args.headers ?? {}) },
|
|
76
|
+
payloadHash,
|
|
77
|
+
},
|
|
78
|
+
credentials: this.config,
|
|
79
|
+
region: this.config.region,
|
|
80
|
+
});
|
|
81
|
+
const qs = args.query
|
|
82
|
+
? '?' + Object.keys(args.query).sort().map((k) => `${uriEncode(k)}=${uriEncode(args.query[k])}`).join('&')
|
|
83
|
+
: '';
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 30_000);
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetch(href + qs, {
|
|
89
|
+
method: args.method,
|
|
90
|
+
headers,
|
|
91
|
+
body: args.body,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
throw new StorageError('driver_error', `S3 ${args.method} ${args.bucket}/${args.key ?? ''} : ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
if (res.status === 404) {
|
|
102
|
+
throw new StorageError('not_found', `S3 : objet introuvable (${args.bucket}/${args.key ?? ''})`);
|
|
103
|
+
}
|
|
104
|
+
if (res.status === 403 || res.status === 401) {
|
|
105
|
+
// Cause n°1 en pratique : horloge décalée, mauvaise région, ou secret erroné.
|
|
106
|
+
throw new StorageError('access_denied', `S3 : accès refusé (${res.status}) — vérifier clés, région et horloge`);
|
|
107
|
+
}
|
|
108
|
+
if (res.status === 412) {
|
|
109
|
+
throw new StorageError('driver_error', `S3 : l'objet existe déjà (ifNotExists)`, { status: 412 });
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const detail = await res.text().catch(() => '');
|
|
113
|
+
throw new StorageError('driver_error', `S3 ${args.method} → HTTP ${res.status}`, { body: detail.slice(0, 300) });
|
|
114
|
+
}
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Écrit un objet.
|
|
119
|
+
*
|
|
120
|
+
* Le corps est mis EN MÉMOIRE avant envoi. C'est un choix assumé : SigV4 exige soit le
|
|
121
|
+
* hash sha256 de la charge utile, soit la signature par blocs (« chunked »), nettement plus
|
|
122
|
+
* complexe. Bufferiser donne un `checksum` fiable et un `Content-Length` exact — au prix de
|
|
123
|
+
* la mémoire. Pour de très gros objets, ce pilote appellera un jour l'upload multipart ;
|
|
124
|
+
* mieux vaut une limite CONNUE qu'un streaming subtilement faux.
|
|
125
|
+
*/
|
|
126
|
+
async put(ref, body, opts) {
|
|
127
|
+
const bytes = await toBytes(body);
|
|
128
|
+
const checksum = hashPayload(bytes);
|
|
129
|
+
const headers = {
|
|
130
|
+
'content-type': opts.mimeType,
|
|
131
|
+
'content-length': String(bytes.length),
|
|
132
|
+
};
|
|
133
|
+
// Création atomique : S3 (et MinIO) refusent l'écriture si l'objet existe → HTTP 412.
|
|
134
|
+
if (opts.ifNotExists)
|
|
135
|
+
headers['if-none-match'] = '*';
|
|
136
|
+
for (const [k, v] of Object.entries(opts.metadata ?? {})) {
|
|
137
|
+
headers[`x-amz-meta-${k.toLowerCase()}`] = v;
|
|
138
|
+
}
|
|
139
|
+
const res = await this.send({
|
|
140
|
+
method: 'PUT',
|
|
141
|
+
bucket: ref.bucket,
|
|
142
|
+
key: ref.path,
|
|
143
|
+
body: bytes,
|
|
144
|
+
headers,
|
|
145
|
+
payloadHash: checksum,
|
|
146
|
+
});
|
|
147
|
+
const etag = (res.headers.get('etag') ?? '').replace(/"/g, '');
|
|
148
|
+
return { ref, size: bytes.length, etag: etag || checksum.slice(0, 32), checksum };
|
|
149
|
+
}
|
|
150
|
+
async get(ref) {
|
|
151
|
+
const res = await this.send({ method: 'GET', bucket: ref.bucket, key: ref.path });
|
|
152
|
+
if (!res.body)
|
|
153
|
+
throw new StorageError('driver_error', 'S3 : réponse sans corps');
|
|
154
|
+
const metadata = {};
|
|
155
|
+
res.headers.forEach((v, k) => {
|
|
156
|
+
if (k.startsWith('x-amz-meta-'))
|
|
157
|
+
metadata[k.slice('x-amz-meta-'.length)] = v;
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
stream: res.body,
|
|
161
|
+
size: Number(res.headers.get('content-length') ?? -1),
|
|
162
|
+
mimeType: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
163
|
+
etag: (res.headers.get('etag') ?? '').replace(/"/g, ''),
|
|
164
|
+
metadata,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
async delete(ref) {
|
|
168
|
+
await this.send({ method: 'DELETE', bucket: ref.bucket, key: ref.path });
|
|
169
|
+
}
|
|
170
|
+
async exists(ref) {
|
|
171
|
+
try {
|
|
172
|
+
await this.send({ method: 'HEAD', bucket: ref.bucket, key: ref.path });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (err instanceof StorageError && err.code === 'not_found')
|
|
177
|
+
return false;
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async stat(ref) {
|
|
182
|
+
const res = await this.send({ method: 'HEAD', bucket: ref.bucket, key: ref.path });
|
|
183
|
+
const metadata = {};
|
|
184
|
+
res.headers.forEach((v, k) => {
|
|
185
|
+
if (k.startsWith('x-amz-meta-'))
|
|
186
|
+
metadata[k.slice('x-amz-meta-'.length)] = v;
|
|
187
|
+
});
|
|
188
|
+
const lastModified = res.headers.get('last-modified');
|
|
189
|
+
return {
|
|
190
|
+
size: Number(res.headers.get('content-length') ?? -1),
|
|
191
|
+
mimeType: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
192
|
+
etag: (res.headers.get('etag') ?? '').replace(/"/g, ''),
|
|
193
|
+
// S3 renvoie `Last-Modified` en date HTTP. Absent (certains services S3-compatibles
|
|
194
|
+
// l'omettent sur HEAD) → epoch, plutôt qu'une `Invalid Date` qui contaminerait l'appelant.
|
|
195
|
+
modifiedAt: lastModified ? new Date(lastModified) : new Date(0),
|
|
196
|
+
metadata,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async list(args) {
|
|
200
|
+
const query = { 'list-type': '2' };
|
|
201
|
+
if (args.prefix)
|
|
202
|
+
query.prefix = args.prefix;
|
|
203
|
+
if (args.limit)
|
|
204
|
+
query['max-keys'] = String(Math.max(1, Math.min(args.limit, 1000)));
|
|
205
|
+
if (args.cursor)
|
|
206
|
+
query['continuation-token'] = args.cursor;
|
|
207
|
+
const res = await this.send({ method: 'GET', bucket: args.bucket, query });
|
|
208
|
+
const xml = await res.text();
|
|
209
|
+
// XML plat de S3 : une extraction par balises suffit, et évite une dépendance de parsing.
|
|
210
|
+
const items = xmlAll(xml, 'Contents').map((entry) => ({
|
|
211
|
+
path: decodeURIComponent(xmlOne(entry, 'Key') ?? ''),
|
|
212
|
+
size: Number(xmlOne(entry, 'Size') ?? 0),
|
|
213
|
+
modifiedAt: new Date(xmlOne(entry, 'LastModified') ?? 0),
|
|
214
|
+
}));
|
|
215
|
+
const truncated = xmlOne(xml, 'IsTruncated') === 'true';
|
|
216
|
+
const nextCursor = truncated ? xmlOne(xml, 'NextContinuationToken') : undefined;
|
|
217
|
+
return { items, ...(nextCursor ? { nextCursor } : {}) };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface SigV4Credentials {
|
|
2
|
+
accessKeyId: string;
|
|
3
|
+
secretAccessKey: string;
|
|
4
|
+
/** Jeton de session (STS / rôles temporaires). */
|
|
5
|
+
sessionToken?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SigV4Request {
|
|
8
|
+
method: string;
|
|
9
|
+
/** Chemin déjà encodé (ex. `/bucket/objet%20a.txt`). */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Paramètres de requête (seront triés et encodés). */
|
|
12
|
+
query?: Record<string, string>;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
/** sha256 hex de la charge utile (`UNSIGNED-PAYLOAD` accepté). */
|
|
15
|
+
payloadHash: string;
|
|
16
|
+
}
|
|
17
|
+
/** sha256 hex d'une charge utile (à passer en `payloadHash`). */
|
|
18
|
+
export declare function hashPayload(body: Uint8Array | string | undefined): string;
|
|
19
|
+
/**
|
|
20
|
+
* Encodage d'URI d'AWS : RFC 3986 STRICT. `encodeURIComponent` laisse passer `!'()*`,
|
|
21
|
+
* qu'AWS attend encodés — un seul caractère divergent, et la signature ne correspond plus.
|
|
22
|
+
*/
|
|
23
|
+
export declare function uriEncode(str: string, encodeSlash?: boolean): string;
|
|
24
|
+
/** Horodatage AWS : `20260713T024500Z` et sa date courte `20260713`. */
|
|
25
|
+
export declare function amzDate(now: Date): {
|
|
26
|
+
amzDate: string;
|
|
27
|
+
dateStamp: string;
|
|
28
|
+
};
|
|
29
|
+
/** Clé de signature dérivée (date → région → service → aws4_request). */
|
|
30
|
+
export declare function signingKey(secret: string, dateStamp: string, region: string, service: string): Buffer;
|
|
31
|
+
/** Requête canonique — l'ordre et l'encodage sont NORMATIFS : la moindre variation casse tout. */
|
|
32
|
+
export declare function canonicalRequest(req: SigV4Request): {
|
|
33
|
+
canonical: string;
|
|
34
|
+
signedHeaders: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Calcule l'en-tête `Authorization` d'une requête SigV4.
|
|
38
|
+
* Retourne aussi les en-têtes à AJOUTER (`x-amz-date`, `x-amz-content-sha256`…).
|
|
39
|
+
*/
|
|
40
|
+
export declare function signRequest(args: {
|
|
41
|
+
req: SigV4Request;
|
|
42
|
+
credentials: SigV4Credentials;
|
|
43
|
+
region: string;
|
|
44
|
+
service?: string;
|
|
45
|
+
now?: Date;
|
|
46
|
+
}): {
|
|
47
|
+
authorization: string;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
};
|
|
50
|
+
/** Exposé pour les tests (vecteurs officiels AWS). */
|
|
51
|
+
export declare const _internal: {
|
|
52
|
+
sha256hex: (data: string | Uint8Array) => string;
|
|
53
|
+
hmac: (key: Buffer | string, data: string) => Buffer;
|
|
54
|
+
ALGO: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @mostajs/blob-store — Signature AWS SigV4 (AWS4-HMAC-SHA256)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
// License: AGPL-3.0-or-later
|
|
4
|
+
//
|
|
5
|
+
// S3 n'est que du HTTP signé : inutile de tirer `@aws-sdk/client-s3` (des dizaines de Mo de
|
|
6
|
+
// dépendances) pour poser et relire des octets. On signe nous-mêmes — et le pilote fonctionne
|
|
7
|
+
// alors avec TOUT service S3-compatible (AWS, MinIO, Scaleway, Wasabi, Backblaze…), pas
|
|
8
|
+
// seulement AWS.
|
|
9
|
+
//
|
|
10
|
+
// Référence : AWS Signature Version 4 — « Create a signed request ».
|
|
11
|
+
// La correction de ce fichier est vérifiée contre les VECTEURS OFFICIELS d'AWS
|
|
12
|
+
// (test-scripts/sigv4.test.mjs) : une signature fausse échouerait en silence côté serveur,
|
|
13
|
+
// avec un « 403 SignatureDoesNotMatch » indéchiffrable.
|
|
14
|
+
// Les primitives viennent de @mostajs/crypto-box — la MAISON UNIQUE des primitives de
|
|
15
|
+
// l'écosystème. blob-store gagne ainsi UNE dépendance (kernel → kernel, autorisée §5.bis) et
|
|
16
|
+
// perd son « zéro dépendance » LITTÉRAL. Le prix est assumé : c'est ce qui garantit qu'un
|
|
17
|
+
// durcissement cryptographique profite à TOUS les modules, au lieu de rester bloqué dans
|
|
18
|
+
// celui qui l'a écrit — la divergence exacte qu'on a trouvée entre url et storage.
|
|
19
|
+
import { sha256, hmacSha256Bytes } from '@mostajs/crypto-box';
|
|
20
|
+
const ALGO = 'AWS4-HMAC-SHA256';
|
|
21
|
+
const sha256hex = (data) => sha256(data);
|
|
22
|
+
const hmac = (key, data) => hmacSha256Bytes(key, data);
|
|
23
|
+
/** sha256 hex d'une charge utile (à passer en `payloadHash`). */
|
|
24
|
+
export function hashPayload(body) {
|
|
25
|
+
return sha256hex(body ?? '');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Encodage d'URI d'AWS : RFC 3986 STRICT. `encodeURIComponent` laisse passer `!'()*`,
|
|
29
|
+
* qu'AWS attend encodés — un seul caractère divergent, et la signature ne correspond plus.
|
|
30
|
+
*/
|
|
31
|
+
export function uriEncode(str, encodeSlash = true) {
|
|
32
|
+
let out = '';
|
|
33
|
+
for (const ch of str) {
|
|
34
|
+
if (/[A-Za-z0-9_.~-]/.test(ch)) {
|
|
35
|
+
out += ch;
|
|
36
|
+
}
|
|
37
|
+
else if (ch === '/') {
|
|
38
|
+
out += encodeSlash ? '%2F' : '/';
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
out += [...new TextEncoder().encode(ch)]
|
|
42
|
+
.map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`)
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** Horodatage AWS : `20260713T024500Z` et sa date courte `20260713`. */
|
|
49
|
+
export function amzDate(now) {
|
|
50
|
+
const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
51
|
+
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
|
|
52
|
+
}
|
|
53
|
+
/** Clé de signature dérivée (date → région → service → aws4_request). */
|
|
54
|
+
export function signingKey(secret, dateStamp, region, service) {
|
|
55
|
+
const kDate = hmac(`AWS4${secret}`, dateStamp);
|
|
56
|
+
const kRegion = hmac(kDate, region);
|
|
57
|
+
const kService = hmac(kRegion, service);
|
|
58
|
+
return hmac(kService, 'aws4_request');
|
|
59
|
+
}
|
|
60
|
+
/** Requête canonique — l'ordre et l'encodage sont NORMATIFS : la moindre variation casse tout. */
|
|
61
|
+
export function canonicalRequest(req) {
|
|
62
|
+
const query = req.query ?? {};
|
|
63
|
+
const canonicalQuery = Object.keys(query)
|
|
64
|
+
.sort()
|
|
65
|
+
.map((k) => `${uriEncode(k)}=${uriEncode(query[k])}`)
|
|
66
|
+
.join('&');
|
|
67
|
+
// En-têtes : noms en minuscules, triés, valeurs « trimmées » et espaces internes compactés.
|
|
68
|
+
const lower = {};
|
|
69
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
70
|
+
lower[k.toLowerCase()] = String(v).trim().replace(/\s+/g, ' ');
|
|
71
|
+
}
|
|
72
|
+
const names = Object.keys(lower).sort();
|
|
73
|
+
const canonicalHeaders = names.map((n) => `${n}:${lower[n]}\n`).join('');
|
|
74
|
+
const signedHeaders = names.join(';');
|
|
75
|
+
const canonical = [
|
|
76
|
+
req.method.toUpperCase(),
|
|
77
|
+
req.path,
|
|
78
|
+
canonicalQuery,
|
|
79
|
+
canonicalHeaders,
|
|
80
|
+
signedHeaders,
|
|
81
|
+
req.payloadHash,
|
|
82
|
+
].join('\n');
|
|
83
|
+
return { canonical, signedHeaders };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Calcule l'en-tête `Authorization` d'une requête SigV4.
|
|
87
|
+
* Retourne aussi les en-têtes à AJOUTER (`x-amz-date`, `x-amz-content-sha256`…).
|
|
88
|
+
*/
|
|
89
|
+
export function signRequest(args) {
|
|
90
|
+
const service = args.service ?? 's3';
|
|
91
|
+
const { amzDate: stamp, dateStamp } = amzDate(args.now ?? new Date());
|
|
92
|
+
const headers = {
|
|
93
|
+
...args.req.headers,
|
|
94
|
+
'x-amz-date': stamp,
|
|
95
|
+
'x-amz-content-sha256': args.req.payloadHash,
|
|
96
|
+
};
|
|
97
|
+
if (args.credentials.sessionToken) {
|
|
98
|
+
headers['x-amz-security-token'] = args.credentials.sessionToken;
|
|
99
|
+
}
|
|
100
|
+
const { canonical, signedHeaders } = canonicalRequest({ ...args.req, headers });
|
|
101
|
+
const scope = `${dateStamp}/${args.region}/${service}/aws4_request`;
|
|
102
|
+
const stringToSign = [ALGO, stamp, scope, sha256hex(canonical)].join('\n');
|
|
103
|
+
const key = signingKey(args.credentials.secretAccessKey, dateStamp, args.region, service);
|
|
104
|
+
const signature = hmacSha256Bytes(key, stringToSign).toString('hex');
|
|
105
|
+
const authorization = `${ALGO} Credential=${args.credentials.accessKeyId}/${scope}, ` +
|
|
106
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
107
|
+
return { authorization, headers: { ...headers, authorization } };
|
|
108
|
+
}
|
|
109
|
+
/** Exposé pour les tests (vecteurs officiels AWS). */
|
|
110
|
+
export const _internal = { sha256hex, hmac, ALGO };
|
package/dist/index.d.ts
CHANGED
|
@@ -24,8 +24,16 @@ export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
|
|
|
24
24
|
export type { StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult, } from './driver.js';
|
|
25
25
|
export { FilesystemDriver } from './drivers/filesystem.js';
|
|
26
26
|
export { CachingDriver } from './drivers/caching.js';
|
|
27
|
+
/**
|
|
28
|
+
* Pilote S3 — et tout service S3-compatible (MinIO, Scaleway, Wasabi, Backblaze…).
|
|
29
|
+
* Zéro dépendance : SigV4 est implémenté ici (S3 n'est que du HTTP signé), plutôt que de
|
|
30
|
+
* tirer `@aws-sdk/client-s3`. Solde la promesse « FS / S3 » que `@mostajs/storage` affichait
|
|
31
|
+
* sans la tenir — et la solde AU BON ÉTAGE, pour qu'archive-box et backup en profitent.
|
|
32
|
+
*/
|
|
33
|
+
export { S3Driver, type S3DriverConfig } from './drivers/s3.js';
|
|
34
|
+
export { signRequest, hashPayload, uriEncode, type SigV4Credentials } from './drivers/sigv4.js';
|
|
27
35
|
export declare const moduleInfo: {
|
|
28
36
|
readonly name: "@mostajs/blob-store";
|
|
29
|
-
readonly version: "0.
|
|
37
|
+
readonly version: "0.2.0";
|
|
30
38
|
readonly level: "N0";
|
|
31
39
|
};
|
package/dist/index.js
CHANGED
|
@@ -22,4 +22,12 @@
|
|
|
22
22
|
export { StorageError, STORAGE_ERROR_HTTP } from './types.js';
|
|
23
23
|
export { FilesystemDriver } from './drivers/filesystem.js';
|
|
24
24
|
export { CachingDriver } from './drivers/caching.js';
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Pilote S3 — et tout service S3-compatible (MinIO, Scaleway, Wasabi, Backblaze…).
|
|
27
|
+
* Zéro dépendance : SigV4 est implémenté ici (S3 n'est que du HTTP signé), plutôt que de
|
|
28
|
+
* tirer `@aws-sdk/client-s3`. Solde la promesse « FS / S3 » que `@mostajs/storage` affichait
|
|
29
|
+
* sans la tenir — et la solde AU BON ÉTAGE, pour qu'archive-box et backup en profitent.
|
|
30
|
+
*/
|
|
31
|
+
export { S3Driver } from './drivers/s3.js';
|
|
32
|
+
export { signRequest, hashPayload, uriEncode } from './drivers/sigv4.js';
|
|
33
|
+
export const moduleInfo = { name: '@mostajs/blob-store', version: '0.2.0', level: 'N0' };
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @mostajs/blob-store — 0.
|
|
1
|
+
# @mostajs/blob-store — 0.2.0
|
|
2
2
|
Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later · Niveau N0 · ZÉRO dépendance
|
|
3
3
|
|
|
4
4
|
RÔLE
|
|
@@ -23,6 +23,11 @@ API
|
|
|
23
23
|
| tenant_violation | signature_invalid | signature_expired | driver_error
|
|
24
24
|
FilesystemDriver new FilesystemDriver({ rootDir }) — refuse '..' (path traversal)
|
|
25
25
|
CachingDriver décorateur de cache devant un driver d'origine
|
|
26
|
+
S3Driver new S3Driver({ endpoint, region, accessKeyId, secretAccessKey,
|
|
27
|
+
forcePathStyle?, sessionToken?, timeoutMs? })
|
|
28
|
+
S3 + TOUT service S3-compatible (MinIO/Scaleway/Wasabi/Backblaze).
|
|
29
|
+
SigV4 intégré (zéro dépendance — pas d'@aws-sdk). Sous-chemin /drivers/s3.
|
|
30
|
+
signRequest, hashPayload, uriEncode — SigV4 exposée (sous-chemin /sigv4)
|
|
26
31
|
|
|
27
32
|
CE QUI N'EST PAS ICI
|
|
28
33
|
FileMeta (row File en base, accountId multi-tenant) reste dans @mostajs/storage : c'est une
|
|
@@ -32,4 +37,7 @@ CONSOMMATEURS
|
|
|
32
37
|
@mostajs/storage (octets + ORM + RBAC) · @mostajs/archive-box · @mostajs/backup
|
|
33
38
|
|
|
34
39
|
TESTS
|
|
35
|
-
npm test
|
|
40
|
+
npm test 28 tests mjs-unit :
|
|
41
|
+
- filesystem : disque réel, sha256 vérifié indépendamment
|
|
42
|
+
- sigv4 : VECTEURS OFFICIELS AWS (get-vanilla) — signature exacte
|
|
43
|
+
- s3 : serveur S3 simulé qui REFUSE toute requête non signée
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/blob-store",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Ranger des octets — contrat StorageDriver
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Ranger des octets — contrat StorageDriver + pilotes filesystem, caching et S3 (SigV4 intégré, tout service S3-compatible). Primitive N0. Primitives crypto déléguées à @mostajs/crypto-box.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
7
7
|
"type": "module",
|
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
"types": "./dist/drivers/caching.d.ts",
|
|
31
31
|
"import": "./dist/drivers/caching.js",
|
|
32
32
|
"default": "./dist/drivers/caching.js"
|
|
33
|
+
},
|
|
34
|
+
"./drivers/s3": {
|
|
35
|
+
"types": "./dist/drivers/s3.d.ts",
|
|
36
|
+
"import": "./dist/drivers/s3.js",
|
|
37
|
+
"default": "./dist/drivers/s3.js"
|
|
38
|
+
},
|
|
39
|
+
"./sigv4": {
|
|
40
|
+
"types": "./dist/drivers/sigv4.d.ts",
|
|
41
|
+
"import": "./dist/drivers/sigv4.js",
|
|
42
|
+
"default": "./dist/drivers/sigv4.js"
|
|
33
43
|
}
|
|
34
44
|
},
|
|
35
45
|
"files": [
|
|
@@ -57,5 +67,8 @@
|
|
|
57
67
|
"@mostajs/mjs-unit": "^0.3.1",
|
|
58
68
|
"@types/node": "^20.19.43",
|
|
59
69
|
"typescript": "^5.6.0"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@mostajs/crypto-box": "^0.2.0"
|
|
60
73
|
}
|
|
61
|
-
}
|
|
74
|
+
}
|