@orthacms/media-provider-azure 0.4.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 +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/azure-storage-provider.d.ts +43 -0
- package/dist/lib/azure-storage-provider.d.ts.map +1 -0
- package/dist/lib/azure-storage-provider.js +188 -0
- package/dist/lib/fake-container-client.d.ts +50 -0
- package/dist/lib/fake-container-client.d.ts.map +1 -0
- package/dist/lib/fake-container-client.js +88 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ortha CMS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,YAAY,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAzureStorageProvider = void 0;
|
|
4
|
+
var azure_storage_provider_1 = require("./lib/azure-storage-provider");
|
|
5
|
+
Object.defineProperty(exports, "createAzureStorageProvider", { enumerable: true, get: function () { return azure_storage_provider_1.createAzureStorageProvider; } });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ContainerClient } from '@azure/storage-blob';
|
|
2
|
+
import type { StorageProvider } from '@orthacms/media-server';
|
|
3
|
+
/**
|
|
4
|
+
* Settings for Azure Blob Storage.
|
|
5
|
+
*
|
|
6
|
+
* Azure is the one major object store with **no S3 compatibility at all** —
|
|
7
|
+
* different protocol, different signature, containers instead of buckets — so
|
|
8
|
+
* it needs its own adapter rather than an endpoint in `provider-s3`.
|
|
9
|
+
*
|
|
10
|
+
* Three ways to connect, in the order most deployments reach for them:
|
|
11
|
+
*
|
|
12
|
+
* 1. `connectionString` — what the portal hands you, and what Azurite prints.
|
|
13
|
+
* 2. `accountName` + `accountKey`.
|
|
14
|
+
* 3. `containerClient` — an already-built client. This is the escape hatch for
|
|
15
|
+
* **managed identity**: build a client with `DefaultAzureCredential` from
|
|
16
|
+
* `@azure/identity` and pass it, and this package stays free of that
|
|
17
|
+
* dependency. It is also what the tests inject.
|
|
18
|
+
*/
|
|
19
|
+
export interface AzureStorageConfig {
|
|
20
|
+
/** Container every blob lands in. */
|
|
21
|
+
container: string;
|
|
22
|
+
/** Full connection string, including the key. */
|
|
23
|
+
connectionString?: string;
|
|
24
|
+
/** Account name, when connecting with an explicit key. */
|
|
25
|
+
accountName?: string;
|
|
26
|
+
/** Account key, paired with `accountName`. */
|
|
27
|
+
accountKey?: string;
|
|
28
|
+
/** Prefix every blob name with this, e.g. to share a container. */
|
|
29
|
+
keyPrefix?: string;
|
|
30
|
+
/** An already-built container client — managed identity, or a test stub. */
|
|
31
|
+
containerClient?: ContainerClient;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Azure Blob Storage {@link StorageProvider}.
|
|
35
|
+
*
|
|
36
|
+
* `capabilities.directUrl` is **computed, not hardcoded**: a SAS token needs a
|
|
37
|
+
* shared key to sign, so a deployment on managed identity gets `false` and its
|
|
38
|
+
* downloads are proxied. Declaring `true` unconditionally would make
|
|
39
|
+
* `MediaServerPlugin` accept `directServe: 'signed-url'` on a deployment that
|
|
40
|
+
* cannot honour it, and the failure would land per-request instead of at boot.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createAzureStorageProvider(config: AzureStorageConfig): StorageProvider;
|
|
43
|
+
//# sourceMappingURL=azure-storage-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"azure-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/azure-storage-provider.ts"],"names":[],"mappings":"AAEA,OAAO,EAMH,KAAK,eAAe,EACvB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAGR,eAAe,EAElB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,kBAAkB;IAC/B,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,eAAe,CAAC;CACrC;AA0FD;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACtC,MAAM,EAAE,kBAAkB,GAC3B,eAAe,CAsIjB"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAzureStorageProvider = createAzureStorageProvider;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_stream_1 = require("node:stream");
|
|
6
|
+
const storage_blob_1 = require("@azure/storage-blob");
|
|
7
|
+
const media_server_1 = require("@orthacms/media-server");
|
|
8
|
+
/** How much of the stream is buffered per block while uploading. */
|
|
9
|
+
const UPLOAD_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
/** How many blocks are in flight at once. */
|
|
11
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
12
|
+
/** Reduces a file name to one safe key segment. Mirrors the other providers. */
|
|
13
|
+
function sanitize(fileName) {
|
|
14
|
+
const cleaned = fileName.replace(/[^A-Za-z0-9_.-]+/g, '_');
|
|
15
|
+
return cleaned === '.' || cleaned === '..' ? `_${cleaned}` : cleaned;
|
|
16
|
+
}
|
|
17
|
+
/** True for the shapes Azure uses to say "no such blob". */
|
|
18
|
+
function isMissing(error) {
|
|
19
|
+
const candidate = error;
|
|
20
|
+
return (candidate?.statusCode === 404 ||
|
|
21
|
+
candidate?.code === 'BlobNotFound' ||
|
|
22
|
+
candidate?.details?.errorCode === 'BlobNotFound' ||
|
|
23
|
+
candidate?.details?.errorCode === 'ContainerNotFound');
|
|
24
|
+
}
|
|
25
|
+
/** Builds the container client from whichever credentials were supplied. */
|
|
26
|
+
function resolveContainer(config) {
|
|
27
|
+
if (config.containerClient) {
|
|
28
|
+
// A caller-built client may carry any credential — including a managed
|
|
29
|
+
// identity, which cannot sign a plain SAS. Signing is therefore off
|
|
30
|
+
// unless the caller also handed us an explicit key.
|
|
31
|
+
return { container: config.containerClient };
|
|
32
|
+
}
|
|
33
|
+
if (config.connectionString) {
|
|
34
|
+
const service = storage_blob_1.BlobServiceClient.fromConnectionString(config.connectionString);
|
|
35
|
+
return {
|
|
36
|
+
container: service.getContainerClient(config.container),
|
|
37
|
+
...(sharedKeyFrom(config.connectionString)
|
|
38
|
+
? { sharedKey: sharedKeyFrom(config.connectionString) }
|
|
39
|
+
: {})
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (config.accountName && config.accountKey) {
|
|
43
|
+
const sharedKey = new storage_blob_1.StorageSharedKeyCredential(config.accountName, config.accountKey);
|
|
44
|
+
const service = new storage_blob_1.BlobServiceClient(`https://${config.accountName}.blob.core.windows.net`, sharedKey);
|
|
45
|
+
return {
|
|
46
|
+
container: service.getContainerClient(config.container),
|
|
47
|
+
sharedKey
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
throw new Error('createAzureStorageProvider needs credentials: a `connectionString`, an ' +
|
|
51
|
+
'`accountName` + `accountKey` pair, or a pre-built `containerClient` ' +
|
|
52
|
+
'(which is how you use a managed identity without this package depending on @azure/identity).');
|
|
53
|
+
}
|
|
54
|
+
/** Pulls the account name/key out of a connection string, when it has them. */
|
|
55
|
+
function sharedKeyFrom(connectionString) {
|
|
56
|
+
const parts = new Map(connectionString
|
|
57
|
+
.split(';')
|
|
58
|
+
.map((pair) => pair.split(/=(.*)/s))
|
|
59
|
+
.filter((pair) => pair.length >= 2)
|
|
60
|
+
.map(([key, value]) => [key.trim(), value.trim()]));
|
|
61
|
+
const name = parts.get('AccountName');
|
|
62
|
+
const key = parts.get('AccountKey');
|
|
63
|
+
// A SAS-based connection string carries no key, and that is a legitimate
|
|
64
|
+
// way to connect — it just cannot mint further SAS tokens.
|
|
65
|
+
return name && key ? new storage_blob_1.StorageSharedKeyCredential(name, key) : undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The Azure Blob Storage {@link StorageProvider}.
|
|
69
|
+
*
|
|
70
|
+
* `capabilities.directUrl` is **computed, not hardcoded**: a SAS token needs a
|
|
71
|
+
* shared key to sign, so a deployment on managed identity gets `false` and its
|
|
72
|
+
* downloads are proxied. Declaring `true` unconditionally would make
|
|
73
|
+
* `MediaServerPlugin` accept `directServe: 'signed-url'` on a deployment that
|
|
74
|
+
* cannot honour it, and the failure would land per-request instead of at boot.
|
|
75
|
+
*/
|
|
76
|
+
function createAzureStorageProvider(config) {
|
|
77
|
+
if (!config.container?.trim()) {
|
|
78
|
+
throw new Error('createAzureStorageProvider requires a container name.');
|
|
79
|
+
}
|
|
80
|
+
const { container, sharedKey } = resolveContainer(config);
|
|
81
|
+
const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
|
|
82
|
+
const blobFor = (storageKey) => container.getBlockBlobClient(storageKey);
|
|
83
|
+
return {
|
|
84
|
+
id: 'azure',
|
|
85
|
+
capabilities: {
|
|
86
|
+
directUrl: Boolean(sharedKey),
|
|
87
|
+
contentTypeMetadata: true,
|
|
88
|
+
streamingPut: true
|
|
89
|
+
},
|
|
90
|
+
async put(object) {
|
|
91
|
+
const storageKey = [
|
|
92
|
+
prefix,
|
|
93
|
+
object.workspaceId,
|
|
94
|
+
object.assetId,
|
|
95
|
+
object.isVariant ? 'variants' : undefined,
|
|
96
|
+
sanitize(object.fileName)
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join('/');
|
|
100
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
101
|
+
let size = 0;
|
|
102
|
+
const meter = new node_stream_1.PassThrough();
|
|
103
|
+
meter.on('data', (chunk) => {
|
|
104
|
+
hash.update(chunk);
|
|
105
|
+
size += chunk.byteLength;
|
|
106
|
+
});
|
|
107
|
+
object.body.pipe(meter);
|
|
108
|
+
// Without forwarding this, an upload whose source dies waits
|
|
109
|
+
// forever on a stream that will never end.
|
|
110
|
+
object.body.on('error', (error) => meter.destroy(error));
|
|
111
|
+
const blob = blobFor(storageKey);
|
|
112
|
+
try {
|
|
113
|
+
await blob.uploadStream(meter, UPLOAD_BUFFER_BYTES, UPLOAD_CONCURRENCY, {
|
|
114
|
+
blobHTTPHeaders: {
|
|
115
|
+
blobContentType: object.contentType
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// A failed block upload leaves uncommitted blocks charged
|
|
121
|
+
// against the account and invisible to a blob listing — and the
|
|
122
|
+
// key never reached a caller, so nothing can reclaim them.
|
|
123
|
+
// Deleting the (uncommitted) blob is what clears them.
|
|
124
|
+
await blob.deleteIfExists().catch(() => undefined);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return { storageKey, size, checksum: hash.digest('hex') };
|
|
128
|
+
},
|
|
129
|
+
async get(storageKey) {
|
|
130
|
+
try {
|
|
131
|
+
const response = await blobFor(storageKey).download();
|
|
132
|
+
const body = response.readableStreamBody;
|
|
133
|
+
if (!body) {
|
|
134
|
+
// Node always populates it; a browser bundle would not, and
|
|
135
|
+
// an empty body here is not something to stream.
|
|
136
|
+
throw new media_server_1.ObjectNotFoundError(storageKey);
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (isMissing(error)) {
|
|
142
|
+
throw new media_server_1.ObjectNotFoundError(storageKey, error);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
async remove(storageKey) {
|
|
148
|
+
// `deleteIfExists` is idempotent by name; the catch covers a
|
|
149
|
+
// gateway that answers 404 anyway, since reclaim is post-commit and
|
|
150
|
+
// best-effort.
|
|
151
|
+
try {
|
|
152
|
+
await blobFor(storageKey).deleteIfExists();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (!isMissing(error))
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
...(sharedKey
|
|
160
|
+
? {
|
|
161
|
+
async directUrl(storageKey, options) {
|
|
162
|
+
// The SAS pins the response headers, exactly as the S3
|
|
163
|
+
// adapter pins them on its query string: a redirect
|
|
164
|
+
// discards the app's own `Content-Disposition`, `nosniff`
|
|
165
|
+
// and CSP, and the stored MIME type is the uploader's
|
|
166
|
+
// claim. Without these two, an uploaded `.html` renders
|
|
167
|
+
// on the storage account's origin.
|
|
168
|
+
const fileName = options.fileName.replace(/"/g, '');
|
|
169
|
+
const expiresOn = new Date(Date.now() + options.expiresInSeconds * 1000);
|
|
170
|
+
const sas = (0, storage_blob_1.generateBlobSASQueryParameters)({
|
|
171
|
+
containerName: container.containerName,
|
|
172
|
+
blobName: storageKey,
|
|
173
|
+
permissions: storage_blob_1.BlobSASPermissions.parse('r'),
|
|
174
|
+
expiresOn,
|
|
175
|
+
contentDisposition: `${options.disposition}; filename="${fileName}"`,
|
|
176
|
+
contentType: options.contentType
|
|
177
|
+
}, sharedKey).toString();
|
|
178
|
+
return `${blobFor(storageKey).url}?${sas}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
async verify() {
|
|
183
|
+
// One properties call at boot: a wrong container, a dead account or
|
|
184
|
+
// an expired key fails the start rather than the first upload.
|
|
185
|
+
await container.getProperties();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import type { ContainerClient } from '@azure/storage-blob';
|
|
3
|
+
/** One blob, as the fake holds it. */
|
|
4
|
+
interface FakeBlob {
|
|
5
|
+
body: Buffer;
|
|
6
|
+
contentType?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A stand-in `ContainerClient` covering the four calls this adapter makes.
|
|
10
|
+
*
|
|
11
|
+
* Azure's client is a class tree, so — unlike the S3 adapter, where a real
|
|
12
|
+
* client with a stubbed `send` keeps the SDK's own middleware in play — the
|
|
13
|
+
* seam here is the container client itself, which the provider already accepts
|
|
14
|
+
* for managed-identity deployments. That means these tests drive the same
|
|
15
|
+
* injection point a real caller uses, rather than a private hook.
|
|
16
|
+
*
|
|
17
|
+
* It proves this adapter's logic and nothing about Azure: **Azurite** and one
|
|
18
|
+
* real storage account are the acceptance step, and are named in `AGENTS.md`.
|
|
19
|
+
*/
|
|
20
|
+
export declare class FakeContainerClient {
|
|
21
|
+
readonly blobs: Map<string, FakeBlob>;
|
|
22
|
+
readonly containerName = "media";
|
|
23
|
+
/** Flip to make `getProperties` fail, as a missing container would. */
|
|
24
|
+
exists: boolean;
|
|
25
|
+
/** Make the next upload fail, to exercise the cleanup path. */
|
|
26
|
+
failNextUpload: boolean;
|
|
27
|
+
/** Blob names `deleteIfExists` was called for, in order. */
|
|
28
|
+
readonly deleted: string[];
|
|
29
|
+
getBlockBlobClient(blobName: string): {
|
|
30
|
+
url: string;
|
|
31
|
+
uploadStream: (stream: NodeJS.ReadableStream, _bufferSize?: number, _concurrency?: number, options?: {
|
|
32
|
+
blobHTTPHeaders?: {
|
|
33
|
+
blobContentType?: string;
|
|
34
|
+
};
|
|
35
|
+
}) => Promise<{}>;
|
|
36
|
+
download: () => Promise<{
|
|
37
|
+
readableStreamBody: Readable;
|
|
38
|
+
}>;
|
|
39
|
+
deleteIfExists: () => Promise<{
|
|
40
|
+
succeeded: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
getProperties(): Promise<{}>;
|
|
44
|
+
/** Blob names currently held, sorted. */
|
|
45
|
+
keys(): string[];
|
|
46
|
+
/** Presents as the real thing to a provider that only uses the above. */
|
|
47
|
+
asContainerClient(): ContainerClient;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=fake-container-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-container-client.d.ts","sourceRoot":"","sources":["../../src/lib/fake-container-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,sCAAsC;AACtC,UAAU,QAAQ;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAmBD;;;;;;;;;;;GAWG;AACH,qBAAa,mBAAmB;IAC5B,QAAQ,CAAC,KAAK,wBAA+B;IAC7C,QAAQ,CAAC,aAAa,WAAW;IACjC,uEAAuE;IACvE,MAAM,UAAQ;IACd,+DAA+D;IAC/D,cAAc,UAAS;IACvB,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAM;IAEhC,kBAAkB,CAAC,QAAQ,EAAE,MAAM;;+BAKf,MAAM,CAAC,cAAc,gBACf,MAAM,iBACL,MAAM,YACX;YAAE,eAAe,CAAC,EAAE;gBAAE,eAAe,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE;;;;;;;;IA8BlE,aAAa;IAKnB,yCAAyC;IACzC,IAAI,IAAI,MAAM,EAAE;IAIhB,yEAAyE;IACzE,iBAAiB,IAAI,eAAe;CAGvC"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FakeContainerClient = void 0;
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
/** The error shape Azure uses for a blob that is not there. */
|
|
6
|
+
function blobNotFound() {
|
|
7
|
+
return Object.assign(new Error('BlobNotFound'), {
|
|
8
|
+
statusCode: 404,
|
|
9
|
+
details: { errorCode: 'BlobNotFound' }
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** Drains an upload stream into one buffer. */
|
|
13
|
+
async function collect(stream) {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
for await (const chunk of stream) {
|
|
16
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat(chunks);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A stand-in `ContainerClient` covering the four calls this adapter makes.
|
|
22
|
+
*
|
|
23
|
+
* Azure's client is a class tree, so — unlike the S3 adapter, where a real
|
|
24
|
+
* client with a stubbed `send` keeps the SDK's own middleware in play — the
|
|
25
|
+
* seam here is the container client itself, which the provider already accepts
|
|
26
|
+
* for managed-identity deployments. That means these tests drive the same
|
|
27
|
+
* injection point a real caller uses, rather than a private hook.
|
|
28
|
+
*
|
|
29
|
+
* It proves this adapter's logic and nothing about Azure: **Azurite** and one
|
|
30
|
+
* real storage account are the acceptance step, and are named in `AGENTS.md`.
|
|
31
|
+
*/
|
|
32
|
+
class FakeContainerClient {
|
|
33
|
+
blobs = new Map();
|
|
34
|
+
containerName = 'media';
|
|
35
|
+
/** Flip to make `getProperties` fail, as a missing container would. */
|
|
36
|
+
exists = true;
|
|
37
|
+
/** Make the next upload fail, to exercise the cleanup path. */
|
|
38
|
+
failNextUpload = false;
|
|
39
|
+
/** Blob names `deleteIfExists` was called for, in order. */
|
|
40
|
+
deleted = [];
|
|
41
|
+
getBlockBlobClient(blobName) {
|
|
42
|
+
// Arrow properties, so `this` stays the fake without aliasing it.
|
|
43
|
+
return {
|
|
44
|
+
url: `https://account.blob.core.windows.net/${this.containerName}/${blobName}`,
|
|
45
|
+
uploadStream: async (stream, _bufferSize, _concurrency, options) => {
|
|
46
|
+
// Drained even when the upload is set to fail: a real client
|
|
47
|
+
// reads the stream before it errors, and not reading it would
|
|
48
|
+
// leave the provider's meter unfed and its `size` at zero.
|
|
49
|
+
const body = await collect(stream);
|
|
50
|
+
if (this.failNextUpload) {
|
|
51
|
+
this.failNextUpload = false;
|
|
52
|
+
throw Object.assign(new Error('upload failed'), {
|
|
53
|
+
statusCode: 500
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
this.blobs.set(blobName, {
|
|
57
|
+
body,
|
|
58
|
+
contentType: options?.blobHTTPHeaders?.blobContentType
|
|
59
|
+
});
|
|
60
|
+
return {};
|
|
61
|
+
},
|
|
62
|
+
download: async () => {
|
|
63
|
+
const blob = this.blobs.get(blobName);
|
|
64
|
+
if (!blob)
|
|
65
|
+
throw blobNotFound();
|
|
66
|
+
return { readableStreamBody: node_stream_1.Readable.from(blob.body) };
|
|
67
|
+
},
|
|
68
|
+
deleteIfExists: async () => {
|
|
69
|
+
this.deleted.push(blobName);
|
|
70
|
+
return { succeeded: this.blobs.delete(blobName) };
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async getProperties() {
|
|
75
|
+
if (!this.exists)
|
|
76
|
+
throw blobNotFound();
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
/** Blob names currently held, sorted. */
|
|
80
|
+
keys() {
|
|
81
|
+
return [...this.blobs.keys()].sort();
|
|
82
|
+
}
|
|
83
|
+
/** Presents as the real thing to a provider that only uses the above. */
|
|
84
|
+
asContainerClient() {
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.FakeContainerClient = FakeContainerClient;
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orthacms/media-provider-azure",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "@orthacms/media-provider-azure — part of Ortha CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/media/provider-azure",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ortha-source/ortha-cms.git",
|
|
10
|
+
"directory": "packages/media/provider-azure"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ortha-source/ortha-cms/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@azure/storage-blob": "^12.26.0",
|
|
29
|
+
"@orthacms/media-server": "^0.4.0",
|
|
30
|
+
"tslib": "^2.3.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|