@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 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
@@ -0,0 +1,7 @@
1
+ # @orthacms/media-provider-azure
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/media-provider-azure
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createAzureStorageProvider } from './lib/azure-storage-provider';
2
+ export type { AzureStorageConfig } from './lib/azure-storage-provider';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ }