@tryghost/gcs-adapter
Version:
Ghost GCS Storage Adapter
246 lines (225 loc) • 8.27 kB
JavaScript
import { Storage } from '@google-cloud/storage';
import config from '@tryghost/config';
import GhostErrors from '@tryghost/errors';
import StorageAdapterBase from 'ghost-storage-base';
import { deburr, defaults } from 'lo';
import { pipeline } from 'node:stream/promises';
import { REGEX } from './constants.mjs';
import { fromFile, fromPath, fromURL } from './file.mjs';
import { GCSAdapterError } from './util.mjs';
const { NotFoundError, BadRequestError, NoPermissionError, InternalServerError, UnauthorizedError } = GhostErrors;
const defs = {
bucket: undefined,
protocol: 'https',
host: 'storage.googleapis.com',
storage: undefined,
hash: false,
hashAlgorithm: 'md5',
hashLength: 16,
prefix: undefined,
template: undefined,
deburr: true,
lowercase: true,
signed: false,
expires: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
filename: 'original',
virtual: true,
passthrough: false,
type: 'images',
addPrefixToURL: false
};
export class GCSAdapter extends StorageAdapterBase {
constructor (opts) {
super(opts);
this.opts = opts = defaults(opts, defs);
if (!opts.prefix) {
opts.prefix = '';
}
if (!opts.template) {
if (opts.hash) {
opts.template = '[hash][ext]';
} else {
opts.template = '[name][ext]';
}
}
if (!opts.host) {
opts.host = defs.host;
}
opts.addBucketToURL = false;
if (opts.host === defs.host) {
opts.addPrefixToURL = true;
if (opts.virtual) {
opts.host = opts.bucket + '.' + opts.host;
} else {
opts.addBucketToURL = true;
}
}
if (opts.signed) {
opts.addPrefixToURL = true;
if (!opts.virtual) {
opts.addBucketToURL = true;
}
}
this.contentPath = config.get('paths:contentPath') ?? 'content';
this.storage = new Storage(opts.storage);
this.bucket = this.storage.bucket(opts.bucket);
}
fromPath (path, dir) {
return fromPath(path, this, dir);
}
fromFile (file, dir) {
return fromFile(file, this, dir);
}
fromURL (url) {
return fromURL(url, this);
}
// Check if file exists in GCS
// Accepts a file path/name and optional target dir
// Returns Boolean
// Note: Used by image size (srcset) middleware
// https://github.com/TryGhost/Ghost/blob/80aa346307fd3e6e57465cebdde4a8a292c722f3/ghost/core/core/frontend/web/middleware/handle-image-sizes.js#L114
async exists (path, dir) {
const file = this.fromPath(path, dir);
return file.exists();
}
// Read file from GCS as Buffer
// Accepts a file path/name
// Returns Buffer
// Note: Used by image size (srcset) middleware
// https://github.com/TryGhost/Ghost/blob/80aa346307fd3e6e57465cebdde4a8a292c722f3/ghost/core/core/frontend/web/middleware/handle-image-sizes.js#L121
async read ({ path }={}) {
const file = this.fromPath(path);
try {
return file.read();
} catch (err) {
throw new GCSAdapterError(err.message);
}
}
// Save file to GCS
// Accepts a file object and optional target dir
// Returns the computed path String to serve file from
// Note: Example in LocalStorageBase class:
// https://github.com/TryGhost/Ghost/blob/9114c9894430f577ea612a9340b0b4813133c000/ghost/core/core/server/adapters/storage/LocalStorageBase.js#L50
async save (obj, dir) {
const file = this.fromFile(obj, dir);
try {
await file.upload();
return file.serve();
} catch (err) {
throw new GCSAdapterError(err.message);
}
}
// Save a raw Buffer to GCS
// Acceps a file Buffer and file path/name
// Returns the computed path String to serve file from
// Note: Used by image size (srcset) middleware
// https://github.com/TryGhost/Ghost/blob/80aa346307fd3e6e57465cebdde4a8a292c722f3/ghost/core/core/frontend/web/middleware/handle-image-sizes.js#L135
async saveRaw (buffer, path) {
const file = this.fromPath(path);
try {
await file.write(buffer);
return file.serve();
} catch (err) {
throw new GCSAdapterError(err.message);
}
}
// Delete file from GCS
// Accepts a file path/name and optional target dir
// Returns Undefined or throws Error on failure
// Note: Used in media handler
// https://github.com/TryGhost/Ghost/blob/6af4d8f01df657ad9ba425f7ac78b50d0088068d/ghost/core/core/server/api/endpoints/media.js#L44C36-L44C42
async delete (path, dir) {
const file = this.fromPath(path, dir);
try {
await file.delete();
} catch (err) {
// Note: Ignore Not Found error until media handler is patched
// https://github.com/TryGhost/Ghost/pull/23156
if (err.code === 404) {
return;
}
throw new GCSAdapterError(err.message);
}
}
// Serve GCS files from content path
// Note: Serve handler only gets called on getContentPath(TYPE)
// If we enable passthrough and return URLs other than contentPath/TYPE (absolute)
// this route handler will not get called
serve () {
const gcs = this;
return async function GCSAdapterServe (req, res, next) {
let file;
try {
file = gcs.fromPath(req.path);
// Note: Need to set some headers
await pipeline(
file.gcsReadStream(),
res
);
} catch (err) {
switch (err.code) {
case 400:
next(new BadRequestError({ err }));
break;
case 401:
next(new UnauthorizedError({ err }));
break;
case 403:
next(new NoPermissionError({ err }));
break;
case 404:
next(new NotFoundError({
message: err.message,
code: 'STATIC_FILE_NOT_FOUND',
property: file.relative()
}));
break;
default:
next(new InternalServerError({ err }));
}
}
}
}
// Convert a computed serve url to GCS relative path
// Accepts a computed serve url (returned from save, saveRaw)
// Returns a GCS relative path
// Note: Used in media and image handlers
// https://github.com/TryGhost/Ghost/blob/6af4d8f01df657ad9ba425f7ac78b50d0088068d/ghost/core/core/server/api/endpoints/media.js#L39
// https://github.com/TryGhost/Ghost/blob/6af4d8f01df657ad9ba425f7ac78b50d0088068d/ghost/core/core/server/api/endpoints/images.js#L61
urlToPath (url) {
const file = this.fromURL(url);
return file.relative();
}
// Sanitize file paths for GCS
// https://cloud.google.com/storage/docs/objects#naming
sanitize (str='') {
const { opts } = this;
// Remove XML control characters
str = str.replace(REGEX.xml, '');
// Remove GCS wildcard and versioning characters
str = str.replace(REGEX.gcs, '');
// Replace whitespace with hyphens
str = str.replace(REGEX.whitespace, '-');
// Remove diacritics
if (opts.deburr ?? opts.asciiFolding) {
str = deburr(str);
}
// Lowercase
if (opts.lowercase) {
str = str.toLowerCase();
}
// Replace slashes with forward slash
return str.replace(REGEX.slash, '/');
}
sanitizeFileName (str) {
return this.sanitize(str);
}
getSanitizedFileName (str) {
return this.sanitize(str);
}
static StorageAdapterBase;
}
export {
StorageAdapterBase
};
export default GCSAdapter;