@orthacms/media-provider-gcs 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-gcs
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/media-provider-gcs
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createGcsStorageProvider } from './lib/gcs-storage-provider';
2
+ export type { GcsStorageConfig } from './lib/gcs-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,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,YAAY,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGcsStorageProvider = void 0;
4
+ var gcs_storage_provider_1 = require("./lib/gcs-storage-provider");
5
+ Object.defineProperty(exports, "createGcsStorageProvider", { enumerable: true, get: function () { return gcs_storage_provider_1.createGcsStorageProvider; } });
@@ -0,0 +1,48 @@
1
+ import { PassThrough, Readable } from 'node:stream';
2
+ import type { Bucket } from '@google-cloud/storage';
3
+ /** One object, as the fake holds it. */
4
+ interface FakeObject {
5
+ body: Buffer;
6
+ contentType?: string;
7
+ }
8
+ /**
9
+ * A stand-in `Bucket` covering the five calls this adapter makes.
10
+ *
11
+ * The seam is the bucket handle, which the provider already accepts for
12
+ * deployments with hand-built auth — so these tests drive the same entry point
13
+ * a real caller uses rather than a private hook.
14
+ *
15
+ * It proves this adapter's logic and nothing about GCS: the fake agrees with
16
+ * whatever the code does. `AGENTS.md` names the acceptance step.
17
+ */
18
+ export declare class FakeBucket {
19
+ readonly objects: Map<string, FakeObject>;
20
+ /** Flip to make `getMetadata` fail, as a missing bucket would. */
21
+ exists: boolean;
22
+ /** Make the next upload fail part-way, to exercise the cleanup path. */
23
+ failNextUpload: boolean;
24
+ /** Object names `delete` was called for, in order. */
25
+ readonly deleted: string[];
26
+ /** Options the last `getSignedUrl` was asked for. */
27
+ lastSignedUrlOptions: Record<string, unknown> | undefined;
28
+ file(name: string): {
29
+ createWriteStream: (options?: {
30
+ contentType?: string;
31
+ }) => PassThrough;
32
+ createReadStream: () => Readable;
33
+ getMetadata: () => Promise<{
34
+ size: number;
35
+ }[]>;
36
+ delete: (options?: {
37
+ ignoreNotFound?: boolean;
38
+ }) => Promise<{}[]>;
39
+ getSignedUrl: (options: Record<string, unknown>) => Promise<string[]>;
40
+ };
41
+ getMetadata(): Promise<{}[]>;
42
+ /** Object names currently held, sorted. */
43
+ keys(): string[];
44
+ /** Presents as the real thing to a provider that only uses the above. */
45
+ asBucket(): Bucket;
46
+ }
47
+ export {};
48
+ //# sourceMappingURL=fake-bucket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-bucket.d.ts","sourceRoot":"","sources":["../../src/lib/fake-bucket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAY,MAAM,aAAa,CAAC;AAC9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEpD,wCAAwC;AACxC,UAAU,UAAU;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAOD;;;;;;;;;GASG;AACH,qBAAa,UAAU;IACnB,QAAQ,CAAC,OAAO,0BAAiC;IACjD,kEAAkE;IAClE,MAAM,UAAQ;IACd,wEAAwE;IACxE,cAAc,UAAS;IACvB,sDAAsD;IACtD,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAM;IAChC,qDAAqD;IACrD,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAE1D,IAAI,CAAC,IAAI,EAAE,MAAM;sCAEqB;YAAE,WAAW,CAAC,EAAE,MAAM,CAAA;SAAE,KAqBxB,WAAW;;;;;2BAkBhB;YAAE,cAAc,CAAC,EAAE,OAAO,CAAA;SAAE;gCAQvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;IAOvD,WAAW;IAKjB,2CAA2C;IAC3C,IAAI,IAAI,MAAM,EAAE;IAIhB,yEAAyE;IACzE,QAAQ,IAAI,MAAM;CAGrB"}
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeBucket = void 0;
4
+ const node_stream_1 = require("node:stream");
5
+ /** The error shape the GCS client raises for a missing object. */
6
+ function objectNotFound() {
7
+ return Object.assign(new Error('No such object'), { code: 404 });
8
+ }
9
+ /**
10
+ * A stand-in `Bucket` covering the five calls this adapter makes.
11
+ *
12
+ * The seam is the bucket handle, which the provider already accepts for
13
+ * deployments with hand-built auth — so these tests drive the same entry point
14
+ * a real caller uses rather than a private hook.
15
+ *
16
+ * It proves this adapter's logic and nothing about GCS: the fake agrees with
17
+ * whatever the code does. `AGENTS.md` names the acceptance step.
18
+ */
19
+ class FakeBucket {
20
+ objects = new Map();
21
+ /** Flip to make `getMetadata` fail, as a missing bucket would. */
22
+ exists = true;
23
+ /** Make the next upload fail part-way, to exercise the cleanup path. */
24
+ failNextUpload = false;
25
+ /** Object names `delete` was called for, in order. */
26
+ deleted = [];
27
+ /** Options the last `getSignedUrl` was asked for. */
28
+ lastSignedUrlOptions;
29
+ file(name) {
30
+ return {
31
+ createWriteStream: (options) => {
32
+ const chunks = [];
33
+ const sink = new node_stream_1.Writable({
34
+ write: (chunk, _encoding, done) => {
35
+ if (this.failNextUpload) {
36
+ this.failNextUpload = false;
37
+ done(new Error('upload failed'));
38
+ return;
39
+ }
40
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
41
+ done();
42
+ }
43
+ });
44
+ sink.on('finish', () => {
45
+ this.objects.set(name, {
46
+ body: Buffer.concat(chunks),
47
+ contentType: options?.contentType
48
+ });
49
+ });
50
+ return sink;
51
+ },
52
+ createReadStream: () => {
53
+ const object = this.objects.get(name);
54
+ // Lazily, exactly like the real client — which is why the
55
+ // provider reads metadata first rather than trusting this.
56
+ if (object)
57
+ return node_stream_1.Readable.from(object.body);
58
+ return new node_stream_1.Readable({
59
+ read() {
60
+ this.destroy(objectNotFound());
61
+ }
62
+ });
63
+ },
64
+ getMetadata: async () => {
65
+ const object = this.objects.get(name);
66
+ if (!object)
67
+ throw objectNotFound();
68
+ return [{ size: object.body.byteLength }];
69
+ },
70
+ delete: async (options) => {
71
+ this.deleted.push(name);
72
+ const existed = this.objects.delete(name);
73
+ if (!existed && !options?.ignoreNotFound) {
74
+ throw objectNotFound();
75
+ }
76
+ return [{}];
77
+ },
78
+ getSignedUrl: async (options) => {
79
+ this.lastSignedUrlOptions = options;
80
+ return [`https://storage.test/${name}?signed=1`];
81
+ }
82
+ };
83
+ }
84
+ async getMetadata() {
85
+ if (!this.exists)
86
+ throw objectNotFound();
87
+ return [{}];
88
+ }
89
+ /** Object names currently held, sorted. */
90
+ keys() {
91
+ return [...this.objects.keys()].sort();
92
+ }
93
+ /** Presents as the real thing to a provider that only uses the above. */
94
+ asBucket() {
95
+ return this;
96
+ }
97
+ }
98
+ exports.FakeBucket = FakeBucket;
@@ -0,0 +1,50 @@
1
+ import { type Bucket } from '@google-cloud/storage';
2
+ import type { StorageProvider } from '@orthacms/media-server';
3
+ /**
4
+ * Settings for Google Cloud Storage.
5
+ *
6
+ * **You may not need this package.** GCS speaks the S3 XML API in
7
+ * interoperability mode, so `@orthacms/media-provider-s3` reaches it today with
8
+ * `endpoint: 'https://storage.googleapis.com'` and an HMAC key. This adapter
9
+ * exists for the deployment that cannot use that: HMAC keys are a long-lived
10
+ * secret that many organizations forbid by policy, and they rule out Workload
11
+ * Identity. Native GCS auth is the reason to be here — if you are happy with an
12
+ * HMAC key, the S3 adapter is one less package to keep.
13
+ */
14
+ export interface GcsStorageConfig {
15
+ /** Bucket every object lands in. */
16
+ bucket: string;
17
+ /** Project the bucket belongs to. Optional under ADC. */
18
+ projectId?: string;
19
+ /** Path to a service-account key file. */
20
+ keyFilename?: string;
21
+ /** Inline service-account credentials, when a file is not an option. */
22
+ credentials?: {
23
+ client_email: string;
24
+ private_key: string;
25
+ };
26
+ /** Prefix every object name with this, e.g. to share a bucket. */
27
+ keyPrefix?: string;
28
+ /**
29
+ * Sign URLs through the IAM `signBlob` API instead of a local private key.
30
+ *
31
+ * This is how a Workload Identity deployment signs: it has no key, and the
32
+ * library asks IAM to sign for it. Opt-in because it needs the
33
+ * `iam.serviceAccounts.signBlob` permission — declaring the capability
34
+ * without it would mean minting URLs that fail at request time.
35
+ */
36
+ signWithIam?: boolean;
37
+ /** An already-built bucket handle — custom auth, or a test stub. */
38
+ bucketClient?: Bucket;
39
+ }
40
+ /**
41
+ * The Google Cloud Storage {@link StorageProvider}.
42
+ *
43
+ * Like the Azure adapter, `capabilities.directUrl` is **computed**: signing
44
+ * needs either a private key or an explicit opt-in to IAM `signBlob`, and a
45
+ * bare Application Default Credentials deployment has neither. A hardcoded
46
+ * `true` would let `MediaServerPlugin` accept `directServe: 'signed-url'` on a
47
+ * deployment that cannot honour it.
48
+ */
49
+ export declare function createGcsStorageProvider(config: GcsStorageConfig): StorageProvider;
50
+ //# sourceMappingURL=gcs-storage-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcs-storage-provider.d.ts","sourceRoot":"","sources":["../../src/lib/gcs-storage-provider.ts"],"names":[],"mappings":"AAGA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D,OAAO,KAAK,EAGR,eAAe,EAElB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC7B,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,WAAW,CAAC,EAAE;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5D,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB;AAkBD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACpC,MAAM,EAAE,gBAAgB,GACzB,eAAe,CAkIjB"}
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGcsStorageProvider = createGcsStorageProvider;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const node_stream_1 = require("node:stream");
6
+ const promises_1 = require("node:stream/promises");
7
+ const storage_1 = require("@google-cloud/storage");
8
+ const media_server_1 = require("@orthacms/media-server");
9
+ /** Reduces a file name to one safe key segment. Mirrors the other providers. */
10
+ function sanitize(fileName) {
11
+ const cleaned = fileName.replace(/[^A-Za-z0-9_.-]+/g, '_');
12
+ return cleaned === '.' || cleaned === '..' ? `_${cleaned}` : cleaned;
13
+ }
14
+ /** True for the shapes GCS uses to say "no such object". */
15
+ function isMissing(error) {
16
+ const candidate = error;
17
+ return (candidate?.code === 404 ||
18
+ candidate?.status === 404 ||
19
+ candidate?.code === 'ENOENT');
20
+ }
21
+ /**
22
+ * The Google Cloud Storage {@link StorageProvider}.
23
+ *
24
+ * Like the Azure adapter, `capabilities.directUrl` is **computed**: signing
25
+ * needs either a private key or an explicit opt-in to IAM `signBlob`, and a
26
+ * bare Application Default Credentials deployment has neither. A hardcoded
27
+ * `true` would let `MediaServerPlugin` accept `directServe: 'signed-url'` on a
28
+ * deployment that cannot honour it.
29
+ */
30
+ function createGcsStorageProvider(config) {
31
+ if (!config.bucket?.trim()) {
32
+ throw new Error('createGcsStorageProvider requires a bucket name.');
33
+ }
34
+ const bucket = config.bucketClient ??
35
+ new storage_1.Storage({
36
+ ...(config.projectId ? { projectId: config.projectId } : {}),
37
+ ...(config.keyFilename ? { keyFilename: config.keyFilename } : {}),
38
+ ...(config.credentials ? { credentials: config.credentials } : {})
39
+ }).bucket(config.bucket);
40
+ const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
41
+ const canSign = Boolean(config.keyFilename || config.credentials || config.signWithIam);
42
+ return {
43
+ id: 'gcs',
44
+ capabilities: {
45
+ directUrl: canSign,
46
+ contentTypeMetadata: true,
47
+ streamingPut: true
48
+ },
49
+ async put(object) {
50
+ const storageKey = [
51
+ prefix,
52
+ object.workspaceId,
53
+ object.assetId,
54
+ object.isVariant ? 'variants' : undefined,
55
+ sanitize(object.fileName)
56
+ ]
57
+ .filter(Boolean)
58
+ .join('/');
59
+ const hash = (0, node_crypto_1.createHash)('sha256');
60
+ let size = 0;
61
+ const meter = new node_stream_1.PassThrough();
62
+ meter.on('data', (chunk) => {
63
+ hash.update(chunk);
64
+ size += chunk.byteLength;
65
+ });
66
+ const file = bucket.file(storageKey);
67
+ try {
68
+ // `pipeline` rather than hand-wired events: it propagates a
69
+ // source error into the write stream and destroys both, which
70
+ // is what stops a dead upload hanging on a stream that will
71
+ // never end.
72
+ await (0, promises_1.pipeline)(object.body, meter, file.createWriteStream({
73
+ resumable: true,
74
+ contentType: object.contentType
75
+ }));
76
+ }
77
+ catch (error) {
78
+ // A resumable upload that dies part-way leaves an incomplete
79
+ // object the bucket will keep until its lifecycle rules sweep
80
+ // it — and the key never reached a caller, so nothing else can
81
+ // reclaim it.
82
+ await file
83
+ .delete({ ignoreNotFound: true })
84
+ .catch(() => undefined);
85
+ throw error;
86
+ }
87
+ return { storageKey, size, checksum: hash.digest('hex') };
88
+ },
89
+ async get(storageKey) {
90
+ const file = bucket.file(storageKey);
91
+ // The metadata read is deliberate. `createReadStream` opens lazily,
92
+ // so a missing object surfaces as an error *on the stream* — by
93
+ // which point the response is already a streaming 200 that can no
94
+ // longer become the 404 the route owes the caller. One HEAD-shaped
95
+ // call buys that back.
96
+ try {
97
+ await file.getMetadata();
98
+ }
99
+ catch (error) {
100
+ if (isMissing(error)) {
101
+ throw new media_server_1.ObjectNotFoundError(storageKey, error);
102
+ }
103
+ throw error;
104
+ }
105
+ return file.createReadStream();
106
+ },
107
+ async remove(storageKey) {
108
+ // Idempotent by flag; the catch covers a gateway that answers 404
109
+ // anyway, since reclaim is post-commit and best-effort.
110
+ try {
111
+ await bucket.file(storageKey).delete({ ignoreNotFound: true });
112
+ }
113
+ catch (error) {
114
+ if (!isMissing(error))
115
+ throw error;
116
+ }
117
+ },
118
+ ...(canSign
119
+ ? {
120
+ async directUrl(storageKey, options) {
121
+ // Same rule as every signing provider: the disposition
122
+ // and content type are pinned onto the URL, because the
123
+ // redirect discards the app's own headers and the stored
124
+ // MIME type is the uploader's claim.
125
+ const fileName = options.fileName.replace(/"/g, '');
126
+ const [url] = await bucket.file(storageKey).getSignedUrl({
127
+ version: 'v4',
128
+ action: 'read',
129
+ expires: Date.now() + options.expiresInSeconds * 1000,
130
+ responseDisposition: `${options.disposition}; filename="${fileName}"`,
131
+ responseType: options.contentType
132
+ });
133
+ return url;
134
+ }
135
+ }
136
+ : {}),
137
+ async verify() {
138
+ // A wrong bucket or a credential that cannot see it fails the boot
139
+ // rather than the first upload.
140
+ await bucket.getMetadata();
141
+ }
142
+ };
143
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@orthacms/media-provider-gcs",
3
+ "version": "0.4.0",
4
+ "description": "@orthacms/media-provider-gcs — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/media/provider-gcs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/media/provider-gcs"
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
+ "@google-cloud/storage": "^7.14.0",
29
+ "@orthacms/media-server": "^0.4.0",
30
+ "tslib": "^2.3.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }