strapi-community / strapi-provider-upload-google-cloud-storage

Google Cloud Storage Upload Provider for Strapi
MIT License
211 stars 94 forks source link

Can't upgrade to Strapi 5 #193

Open squelix opened 1 month ago

squelix commented 1 month ago

When this plugin will be updated to support Strapi v5 ?

Bodokh commented 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.

guissalustiano commented 2 weeks ago

The provider API has not changed. This provider still works with strapi 5.