Open squelix opened 1 month ago
I used O1 to generate an implementation for cloud storage using the S3 official plugin as an example For me it works great, just add it as a custom provider as stated in the strapi docs
Here is the main plugin code in typescript (i pretty much copied also the build stuff from the S3 provider github code)
import {
Bucket,
File as GCSFile,
GetSignedUrlConfig,
Storage
} from '@google-cloud/storage';
import type { Credentials } from 'google-auth-library';
import { getOr } from 'lodash/fp';
import type { ReadStream } from 'node:fs';
import { isUrlFromBucket } from './utils';
export interface File {
name: string;
alternativeText?: string;
caption?: string;
width?: number;
height?: number;
formats?: Record<string, unknown>;
hash: string;
ext?: string;
mime: string;
size: number;
sizeInBytes: number;
url: string;
previewUrl?: string;
path?: string;
provider?: string;
provider_metadata?: Record<string, unknown>;
stream?: ReadStream;
buffer?: Buffer;
}
export interface GCSParams {
ACL?: string; // e.g., 'publicRead'
signedUrlExpires?: number; // in seconds
}
export interface DefaultOptions {
credentials?: Credentials;
projectId?: string;
bucket: string; // making it required
params?: GCSParams;
[k: string]: any;
}
export type InitOptions = DefaultOptions & {
baseUrl?: string;
rootPath?: string;
[k: string]: any;
};
const getConfig = ({ baseUrl, rootPath, ...options }: InitOptions) => {
const { credentials, projectId, params, ...legacyOptions } = options;
if (Object.keys(legacyOptions).length > 0) {
process.emitWarning(
"Google Cloud Storage configuration options passed at root level of the plugin's providerOptions are deprecated and will be removed in a future release. Please ensure all configurations are properly nested."
);
}
const config = {
credentials,
projectId,
...legacyOptions,
...(credentials ? { credentials } : {}),
params: {
ACL: getOr('publicRead', ['params', 'ACL'], { params }),
...params,
},
};
return config;
};
export default {
init({ baseUrl, rootPath, ...options }: InitOptions) {
console.log('options', options);
const config = getConfig({ baseUrl, rootPath, ...options });
const storage = new Storage({
projectId: config.projectId,
credentials: config.credentials,
});
console.log('config', config);
const bucket: Bucket = storage.bucket(config.bucket);
const filePrefix = rootPath ? `${rootPath.replace(/\/+$/, '')}/` : '';
const getFileKey = (file: File) => {
const path = file.path ? `${file.path}/` : '';
return `${filePrefix}${path}${file.hash}${file.ext}`;
};
const upload = async (file: File, customParams: Partial<GCSParams> = {}) => {
const fileKey = getFileKey(file);
const gcsFile: GCSFile = bucket.file(fileKey);
const stream = gcsFile.createWriteStream({
resumable: false,
metadata: {
contentType: file.mime,
cacheControl: 'public, max-age=31536000',
...customParams,
},
predefinedAcl: 'publicRead',
});
return new Promise<void>((resolve, reject) => {
if (file.stream) {
file.stream.pipe(stream)
.on('error', reject)
.on('finish', () => {
file.url = baseUrl ? `${baseUrl}/${fileKey}` : gcsFile.publicUrl();
resolve();
});
} else if (file.buffer) {
stream.end(file.buffer, () => {
file.url = baseUrl ? `${baseUrl}/${fileKey}` : gcsFile.publicUrl();
resolve();
});
} else {
reject(new Error('File must have either a stream or buffer'));
}
});
};
return {
isPrivate() {
return config.params.ACL === 'private';
},
async getSignedUrl(file: File, customParams: any): Promise<{ url: string }> {
// Do not sign the url if it does not come from the same bucket.
if (!isUrlFromBucket(file.url, config.bucket, baseUrl)) {
return { url: file.url };
}
const fileKey = getFileKey(file);
const gcsFile = bucket.file(fileKey);
const options: GetSignedUrlConfig = {
version: 'v4',
action: 'read',
expires: Date.now() + (getOr(15 * 60, ['params', 'signedUrlExpires'], config) * 1000),
...customParams,
};
const [url] = await gcsFile.getSignedUrl(options);
return { url };
},
uploadStream(file: File, customParams = {}) {
return upload(file, customParams);
},
upload(file: File, customParams = {}) {
return upload(file, customParams);
},
async delete(file: File, customParams = {}): Promise<void> {
const fileKey = getFileKey(file);
const gcsFile = bucket.file(fileKey);
await gcsFile.delete({ ignoreNotFound: true, ...customParams });
},
};
},
};
util:
export const isUrlFromBucket = (url: string, bucket: string, baseUrl: string) => {
return url.startsWith(`https://storage.googleapis.com/${bucket}/`);
};
then just update middlewares:
export default [
'strapi::logger',
'strapi::errors',
// 'strapi::security',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': [
"'self'",
'data:',
'blob:',
'yourbucket.storage.googleapis.com',
'storage.googleapis.com',
],
'media-src': [
"'self'",
'data:',
'blob:',
'yourbucket.storage.googleapis.com',
'storage.googleapis.com',
],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
Good luck.
The provider API has not changed. This provider still works with strapi 5.
When this plugin will be updated to support Strapi v5 ?