Move Flydrive home and implement Azure (#4110)

* Add new packages

* Update docs

* Update linked packages

* Setup file pointers

* Don't require getStream to be async

* List credits

* Load azure in storage

* Fix typo in docs

* Fix another typo

* Remove not about raising an issue
This commit is contained in:
Rijk van Zanten
2021-02-16 18:33:50 -05:00
committed by GitHub
parent 4250c47e04
commit 35830a5dfe
40 changed files with 2643 additions and 1047 deletions

View File

@@ -70,10 +70,11 @@
"@directus/format-title": "file:../packages/format-title",
"@directus/schema": "file:../packages/schema",
"@directus/specs": "file:../packages/specs",
"@directus/drive": "file:../packages/drive",
"@directus/drive-gcs": "file:../packages/drive-gcs",
"@directus/drive-s3": "file:../packages/drive-s3",
"@directus/drive-azure": "file:../packages/drive-azure",
"@godaddy/terminus": "^4.4.1",
"@slynova/flydrive": "^1.0.3",
"@slynova/flydrive-gcs": "^1.0.3",
"@slynova/flydrive-s3": "^1.0.3",
"argon2": "^0.27.0",
"atob": "^2.1.2",
"axios": "^0.21.0",

View File

@@ -1,12 +1,13 @@
import { StorageManager, LocalFileSystemStorage, StorageManagerConfig, Storage } from '@slynova/flydrive';
import { StorageManager, LocalFileSystemStorage, StorageManagerConfig, Storage } from '@directus/drive';
import env from './env';
import { validateEnv } from './utils/validate-env';
import { getConfigFromEnv } from './utils/get-config-from-env';
import { toArray } from './utils/to-array';
/** @todo dynamically load these storage adapters */
import { AmazonWebServicesS3Storage } from '@slynova/flydrive-s3';
import { GoogleCloudStorage } from '@slynova/flydrive-gcs';
import { AmazonWebServicesS3Storage } from '@directus/drive-s3';
import { GoogleCloudStorage } from '@directus/drive-gcs';
import { AzureBlobWebServicesStorage } from '@directus/drive-azure';
validateEnv(['STORAGE_LOCATIONS']);
@@ -65,5 +66,7 @@ function getStorageDriver(driver: string) {
return AmazonWebServicesS3Storage;
case 'gcs':
return GoogleCloudStorage;
case 'azure':
return AzureBlobWebServicesStorage;
}
}

View File

@@ -1,6 +1,7 @@
# Files
> Directus offers a full Digital Asset Management (DAM) system. This includes multiple storage adapters, nested folder organization, private file access, image editing, and on-demand thumbnail generation.
> Directus offers a full Digital Asset Management (DAM) system. This includes multiple storage adapters, nested folder
> organization, private file access, image editing, and on-demand thumbnail generation.
Directus allows you to manage all your files in one place, including documents, images, videos, and more. Files can be
uploaded to the [File Library](/concepts/application/#file-library) in general, or directly to an item via a
@@ -43,6 +44,7 @@ following adapters:
- **Local Filesystem** — The default, any filesystem location or network-attached storage
- **S3 or Equivalent** — Including AWS S3, DigitalOcean Spaces, Alibaba OSS, and others
- **Google Cloud Storage** — A RESTful web service on the Google Cloud Platform
- **Azure Blob Storage** — Azure storage account containers
## Thumbnail Transformations

View File

@@ -155,10 +155,10 @@ Alternatively, you can provide the individual connection parameters:
For each of the storage locations listed, you must provide the following configuration:
| Variable | Description | Default Value |
| ------------------------------- | ------------------------------------------------------- | ------------- |
| `STORAGE_<LOCATION>_PUBLIC_URL` | Location on the internet where the files are accessible | |
| `STORAGE_<LOCATION>_DRIVER` | Which driver to use, either `local`, `s3`, or `gcs` | |
| Variable | Description | Default Value |
| ------------------------------- | --------------------------------------------------------- | ------------- |
| `STORAGE_<LOCATION>_PUBLIC_URL` | Location on the internet where the files are accessible | |
| `STORAGE_<LOCATION>_DRIVER` | Which driver to use, either `local`, `s3`, `gcs`, `azure` | |
Based on your configured driver, you must also provide the following configurations:
@@ -178,6 +178,14 @@ Based on your configured driver, you must also provide the following configurati
| `STORAGE_<LOCATION>_BUCKET` | S3 Bucket | -- |
| `STORAGE_<LOCATION>_REGION` | S3 Region | -- |
### Azure (`azure`)
| Variable | Description | Default Value |
| ----------------------------------- | -------------------------- | ------------- |
| `STORAGE_<LOCATION>_CONTAINER_NAME` | Azure Storage container | -- |
| `STORAGE_<LOCATION>_ACCOUNT_NAME` | Azure Storage account name | -- |
| `STORAGE_<LOCATION>_ACCOUNT_KEY` | Azure Storage key | -- |
### Google Cloud Storage (`gcs`)
| Variable | Description | Default Value |

1935
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -183,7 +183,14 @@
"vuepress": "^1.5.2",
"webpack": "^4.43.0",
"webpack-assets-manifest": "^3.1.1",
"webpack-merge": "^5.4.0"
"webpack-merge": "^5.4.0",
"dompurify": "^2.2.6",
"escape-string-regexp": "^4.0.0",
"front-matter": "^4.0.2",
"markdown-it": "^12.0.4",
"markdown-it-anchor": "^7.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-table-of-contents": "^0.5.2"
},
"dependencies": {
"@directus/app": "file:app",
@@ -192,15 +199,12 @@
"@directus/gatsby-source-directus": "file:packages/gatsby-source-directus",
"@directus/sdk-js": "file:packages/sdk-js",
"@directus/specs": "file:packages/specs",
"@directus/drive": "file:packages/drive",
"@directus/drive-gcs": "file:packages/drive-gcs",
"@directus/drive-s3": "file:packages/drive-s3",
"@directus/drive-azure": "file:packages/drive-azure",
"create-directus-project": "file:packages/create-directus-project",
"directus": "file:api",
"dompurify": "^2.2.6",
"escape-string-regexp": "^4.0.0",
"front-matter": "^4.0.2",
"markdown-it": "^12.0.4",
"markdown-it-anchor": "^7.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-table-of-contents": "^0.5.2"
"directus": "file:api"
},
"husky": {
"hooks": {

View File

@@ -0,0 +1,51 @@
{
"name": "drive-azure",
"version": "1.0.0",
"description": "Azure Blob driver for @directus/drive",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"storage",
"filesystem",
"file",
"azure",
"azure-blob",
"promise",
"async",
"spaces",
"drive",
"directus"
],
"scripts": {
"build": "tsc -b tsconfig.json"
},
"author": "Robin Grundvåg <robgru52@gmail.com>",
"contributors": [
"Rijk van Zanten <rijkvanzanten@me.com>"
],
"files": [
"dist"
],
"dependencies": {
"@azure/storage-blob": "^12.2.1"
},
"peerDependencies": {
"@directus/drive": "^1.0.0"
},
"devDependencies": {
"@directus/drive": "file:../drive",
"@types/fs-extra": "^9.0.1",
"fs-extra": "^9.0.0"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/directus/directus.git"
},
"bugs": {
"url": "https://github.com/directus/directus/issues"
}
}

View File

@@ -0,0 +1,3 @@
# @directus/drive-azure
Azure storage layer for `@directus/drive`

View File

@@ -0,0 +1,215 @@
import {
Storage,
UnknownException,
FileNotFound,
SignedUrlOptions,
Response,
ExistsResponse,
ContentResponse,
SignedUrlResponse,
StatResponse,
FileListResponse,
DeleteResponse,
isReadableStream,
} from '@directus/drive';
import {
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
generateBlobSASQueryParameters,
ContainerSASPermissions,
} from '@azure/storage-blob';
import { PassThrough, Readable } from 'stream';
function handleError(err: Error, path: string): Error {
return new UnknownException(err, err.name, path);
}
export class AzureBlobWebServicesStorage extends Storage {
protected $client: BlobServiceClient;
protected $containerClient: ContainerClient;
protected $signedCredentials: StorageSharedKeyCredential;
constructor(config: AzureBlobWebServicesStorageConfig) {
super();
this.$signedCredentials = new StorageSharedKeyCredential(config.accountName, config.accountKey);
this.$client = new BlobServiceClient(
`https://${config.accountName}.blob.core.windows.net`,
this.$signedCredentials
);
this.$containerClient = this.$client.getContainerClient(config.containerName);
}
public async copy(src: string, dest: string): Promise<Response> {
try {
const source = this.$containerClient.getBlockBlobClient(src);
const target = this.$containerClient.getBlockBlobClient(dest);
const poller = await target.beginCopyFromURL(source.url);
const result = await poller.pollUntilDone();
return { raw: result };
} catch (e) {
throw handleError(e, src);
}
}
public async delete(location: string): Promise<DeleteResponse> {
try {
const result = await this.$containerClient.getBlockBlobClient(location).deleteIfExists();
return { raw: result, wasDeleted: result.succeeded };
} catch (e) {
throw handleError(e, location);
}
}
public driver(): BlobServiceClient {
return this.$client;
}
public async exists(location: string): Promise<ExistsResponse> {
try {
const result = await this.$containerClient.getBlockBlobClient(location).exists();
return { exists: result, raw: result };
} catch (e) {
throw handleError(e, location);
}
}
public async get(location: string, encoding: BufferEncoding = 'utf-8'): Promise<ContentResponse<string>> {
try {
const bufferResult = await this.getBuffer(location);
return {
content: bufferResult.content.toString(encoding),
raw: bufferResult.raw,
};
} catch (e) {
throw new FileNotFound(e, location);
}
}
public async getBuffer(location: string): Promise<ContentResponse<Buffer>> {
try {
const client = this.$containerClient.getBlobClient(location);
return { content: await client.downloadToBuffer(), raw: client };
} catch (e) {
throw handleError(e, location);
}
}
public async getSignedUrl(location: string, options: SignedUrlOptions = {}): Promise<SignedUrlResponse> {
const { expiry = 900 } = options;
try {
const client = this.$containerClient.getBlobClient(location);
const blobSAS = generateBlobSASQueryParameters(
{
containerName: this.$containerClient.containerName,
blobName: location,
permissions: ContainerSASPermissions.parse('racwdl'),
startsOn: new Date(),
expiresOn: new Date(new Date().valueOf() + expiry),
},
this.$signedCredentials
).toString();
const sasUrl = client.url + '?' + blobSAS;
return { signedUrl: sasUrl, raw: client };
} catch (e) {
throw handleError(e, location);
}
}
public async getStat(location: string): Promise<StatResponse> {
try {
const props = await this.$containerClient.getBlobClient(location).getProperties();
return {
size: props.contentLength as number,
modified: props.lastModified as Date,
raw: props,
};
} catch (e) {
throw handleError(e, location);
}
}
public getStream(location: string): NodeJS.ReadableStream {
const intermediateStream = new PassThrough({ highWaterMark: 1 });
const stream = this.$containerClient.getBlobClient(location).download();
try {
stream
.then((result) => result.readableStreamBody)
.then((stream) => {
if (!stream) {
throw handleError(new Error('Blobclient stream was not available'), location);
}
stream.pipe(intermediateStream);
})
.catch((error) => {
intermediateStream.emit('error', error);
});
} catch (error) {
intermediateStream.emit('error', error);
}
return intermediateStream;
}
public getUrl(location: string): string {
return this.$containerClient.getBlobClient(location).url;
}
public async move(src: string, dest: string): Promise<Response> {
const source = this.$containerClient.getBlockBlobClient(src);
const target = this.$containerClient.getBlockBlobClient(dest);
const poller = await target.beginCopyFromURL(source.url);
const result = await poller.pollUntilDone();
await source.deleteIfExists();
return { raw: result };
}
public async put(location: string, content: Buffer | NodeJS.ReadableStream | string): Promise<Response> {
const blockBlobClient = this.$containerClient.getBlockBlobClient(location);
try {
if (isReadableStream(content)) {
const result = await blockBlobClient.uploadStream(content as Readable);
return { raw: result };
}
const result = await blockBlobClient.upload(content, content.length);
return { raw: result };
} catch (e) {
throw handleError(e, location);
}
}
public async *flatList(prefix = ''): AsyncIterable<FileListResponse> {
try {
const blobs = await this.$containerClient.listBlobsFlat();
for await (const blob of blobs) {
yield {
raw: blob,
path: blob.name as string,
};
}
} catch (e) {
throw handleError(e, prefix);
}
}
}
export interface AzureBlobWebServicesStorageConfig {
containerName: string;
accountName: string;
accountKey: string;
}

View File

@@ -0,0 +1 @@
export * from './AzureBlobWebServices';

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"lib": ["es2018"],
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "tests", "dist"]
}

View File

@@ -0,0 +1,49 @@
{
"name": "@directus/drive-gcs",
"version": "1.0.0",
"description": "Google Cloud Storage driver for @directus/drive",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"storage",
"filesystem",
"file",
"promise",
"async",
"google",
"cloud",
"drive",
"directus"
],
"author": "Robin Grundvåg <robgru52@gmail.com>",
"contributors": [
"Rijk van Zanten <rijkvanzanten@me.com>"
],
"files": [
"dist"
],
"scripts": {
"build": "tsc -b tsconfig.json"
},
"dependencies": {
"@google-cloud/storage": "^5.0.0"
},
"peerDependencies": {
"@directus/drive": "file:../drive"
},
"devDependencies": {
"@lukeed/uuid": "^1.0.1",
"@directus/drive": "file:../drive"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/directus/directus.git"
},
"bugs": {
"url": "https://github.com/directus/directus/issues"
}
}

View File

@@ -0,0 +1,3 @@
# @directus/drive-gcs
Google Cloud Storage storage layer for `@directus/drive`

View File

@@ -0,0 +1,251 @@
import {
Storage as GCSDriver,
StorageOptions,
Bucket,
File,
GetFilesOptions,
GetFilesResponse,
} from '@google-cloud/storage';
import {
Storage,
isReadableStream,
pipeline,
Response,
ExistsResponse,
ContentResponse,
SignedUrlResponse,
SignedUrlOptions,
StatResponse,
FileListResponse,
DeleteResponse,
FileNotFound,
PermissionMissing,
UnknownException,
AuthorizationRequired,
WrongKeyPath,
} from '@directus/drive';
function handleError(err: Error & { code?: number | string }, path: string): Error {
switch (err.code) {
case 401:
return new AuthorizationRequired(err, path);
case 403:
return new PermissionMissing(err, path);
case 404:
return new FileNotFound(err, path);
case 'ENOENT':
return new WrongKeyPath(err, path);
default:
return new UnknownException(err, String(err.code), path);
}
}
export class GoogleCloudStorage extends Storage {
protected $config: GoogleCloudStorageConfig;
protected $driver: GCSDriver;
protected $bucket: Bucket;
public constructor(config: GoogleCloudStorageConfig) {
super();
this.$config = config;
const GCSStorage = require('@google-cloud/storage').Storage;
this.$driver = new GCSStorage(config);
this.$bucket = this.$driver.bucket(config.bucket);
}
private _file(path: string): File {
return this.$bucket.file(path);
}
/**
* Copy a file to a location.
*/
public async copy(src: string, dest: string): Promise<Response> {
const srcFile = this._file(src);
const destFile = this._file(dest);
try {
const result = await srcFile.copy(destFile);
return { raw: result };
} catch (e) {
throw handleError(e, src);
}
}
/**
* Delete existing file.
*/
public async delete(location: string): Promise<DeleteResponse> {
try {
const result = await this._file(location).delete();
return { raw: result, wasDeleted: true };
} catch (e) {
e = handleError(e, location);
if (e instanceof FileNotFound) {
return { raw: undefined, wasDeleted: false };
}
throw e;
}
}
/**
* Returns the driver.
*/
public driver(): GCSDriver {
return this.$driver;
}
/**
* Determines if a file or folder already exists.
*/
public async exists(location: string): Promise<ExistsResponse> {
try {
const result = await this._file(location).exists();
return { exists: result[0], raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns the file contents.
*/
public async get(location: string, encoding: BufferEncoding = 'utf-8'): Promise<ContentResponse<string>> {
try {
const result = await this._file(location).download();
return { content: result[0].toString(encoding), raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns the file contents as Buffer.
*/
public async getBuffer(location: string): Promise<ContentResponse<Buffer>> {
try {
const result = await this._file(location).download();
return { content: result[0], raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns signed url for an existing file.
*/
public async getSignedUrl(location: string, options: SignedUrlOptions = {}): Promise<SignedUrlResponse> {
const { expiry = 900 } = options;
try {
const result = await this._file(location).getSignedUrl({
action: 'read',
expires: Date.now() + expiry * 1000,
});
return { signedUrl: result[0], raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns file's size and modification date.
*/
public async getStat(location: string): Promise<StatResponse> {
try {
const result = await this._file(location).getMetadata();
return {
size: Number(result[0].size),
modified: new Date(result[0].updated),
raw: result,
};
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns the stream for the given file.
*/
public getStream(location: string): NodeJS.ReadableStream {
return this._file(location).createReadStream();
}
/**
* Returns URL for a given location. Note this method doesn't
* validates the existence of file or it's visibility
* status.
*/
public getUrl(location: string): string {
return `https://storage.googleapis.com/${this.$bucket.name}/${location}`;
}
/**
* Move file to a new location.
*/
public async move(src: string, dest: string): Promise<Response> {
const srcFile = this._file(src);
const destFile = this._file(dest);
try {
const result = await srcFile.move(destFile);
return { raw: result };
} catch (e) {
throw handleError(e, src);
}
}
/**
* Creates a new file.
* This method will create missing directories on the fly.
*/
public async put(location: string, content: Buffer | NodeJS.ReadableStream | string): Promise<Response> {
const file = this._file(location);
try {
if (isReadableStream(content)) {
const destStream = file.createWriteStream();
await pipeline(content, destStream);
return { raw: undefined };
}
const result = await file.save(content, { resumable: false });
return { raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Iterate over all files in the bucket.
*/
public async *flatList(prefix = ''): AsyncIterable<FileListResponse> {
let nextQuery: GetFilesOptions | undefined = {
prefix,
autoPaginate: false,
maxResults: 1000,
};
do {
try {
const result = (await this.$bucket.getFiles(nextQuery)) as GetFilesResponse;
nextQuery = result[1];
for (const file of result[0]) {
yield {
raw: file.metadata,
path: file.name,
};
}
} catch (e) {
throw handleError(e, prefix);
}
} while (nextQuery);
}
}
export interface GoogleCloudStorageConfig extends StorageOptions {
bucket: string;
}

View File

@@ -0,0 +1 @@
export * from './GoogleCloudStorage';

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"lib": ["es2018"],
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "tests", "dist"]
}

View File

@@ -0,0 +1,51 @@
{
"name": "@directus/drive-s3",
"version": "1.0.0",
"description": "AWS S3 driver for @directus/drive",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"storage",
"filesystem",
"file",
"aws",
"s3",
"promise",
"async",
"spaces",
"drive",
"azure"
],
"author": "Robin Grundvåg <robgru52@gmail.com>",
"contributors": [
"Rijk van Zanten <rijkvanzanten@me.com>"
],
"files": [
"dist"
],
"scripts": {
"build": "tsc -b tsconfig.json"
},
"dependencies": {
"aws-sdk": "^2.680.0"
},
"peerDependencies": {
"@directus/drive": "file:../drive"
},
"devDependencies": {
"@directus/drive": "file:../drive",
"@types/fs-extra": "^9.0.1",
"fs-extra": "^9.0.0"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/directus/directus.git"
},
"bugs": {
"url": "https://github.com/directus/directus/issues"
}
}

View File

@@ -0,0 +1,3 @@
# @directus/drive-s3
S3 storage layer for `@directus/drive`

View File

@@ -0,0 +1,256 @@
import S3, { ClientConfiguration, ObjectList } from 'aws-sdk/clients/s3';
import {
Storage,
UnknownException,
NoSuchBucket,
FileNotFound,
PermissionMissing,
SignedUrlOptions,
Response,
ExistsResponse,
ContentResponse,
SignedUrlResponse,
StatResponse,
FileListResponse,
DeleteResponse,
} from '@directus/drive';
function handleError(err: Error, path: string, bucket: string): Error {
switch (err.name) {
case 'NoSuchBucket':
return new NoSuchBucket(err, bucket);
case 'NoSuchKey':
return new FileNotFound(err, path);
case 'AllAccessDisabled':
return new PermissionMissing(err, path);
default:
return new UnknownException(err, err.name, path);
}
}
export class AmazonWebServicesS3Storage extends Storage {
protected $driver: S3;
protected $bucket: string;
constructor(config: AmazonWebServicesS3StorageConfig) {
super();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const S3 = require('aws-sdk/clients/s3');
this.$driver = new S3({
accessKeyId: config.key,
secretAccessKey: config.secret,
...config,
});
this.$bucket = config.bucket;
}
/**
* Copy a file to a location.
*/
public async copy(src: string, dest: string): Promise<Response> {
const params = {
Key: dest,
Bucket: this.$bucket,
CopySource: `/${this.$bucket}/${src}`,
};
try {
const result = await this.$driver.copyObject(params).promise();
return { raw: result };
} catch (e) {
throw handleError(e, src, this.$bucket);
}
}
/**
* Delete existing file.
*/
public async delete(location: string): Promise<DeleteResponse> {
const params = { Key: location, Bucket: this.$bucket };
try {
const result = await this.$driver.deleteObject(params).promise();
// Amazon does not inform the client if anything was deleted.
return { raw: result, wasDeleted: null };
} catch (e) {
throw handleError(e, location, this.$bucket);
}
}
/**
* Returns the driver.
*/
public driver(): S3 {
return this.$driver;
}
/**
* Determines if a file or folder already exists.
*/
public async exists(location: string): Promise<ExistsResponse> {
const params = { Key: location, Bucket: this.$bucket };
try {
const result = await this.$driver.headObject(params).promise();
return { exists: true, raw: result };
} catch (e) {
if (e.statusCode === 404) {
return { exists: false, raw: e };
} else {
throw handleError(e, location, this.$bucket);
}
}
}
/**
* Returns the file contents.
*/
public async get(location: string, encoding: BufferEncoding = 'utf-8'): Promise<ContentResponse<string>> {
const bufferResult = await this.getBuffer(location);
return {
content: bufferResult.content.toString(encoding),
raw: bufferResult.raw,
};
}
/**
* Returns the file contents as Buffer.
*/
public async getBuffer(location: string): Promise<ContentResponse<Buffer>> {
const params = { Key: location, Bucket: this.$bucket };
try {
const result = await this.$driver.getObject(params).promise();
// S3.getObject returns a Buffer in Node.js
const body = result.Body as Buffer;
return { content: body, raw: result };
} catch (e) {
throw handleError(e, location, this.$bucket);
}
}
/**
* Returns signed url for an existing file
*/
public async getSignedUrl(location: string, options: SignedUrlOptions = {}): Promise<SignedUrlResponse> {
const { expiry = 900 } = options;
try {
const params = {
Key: location,
Bucket: this.$bucket,
Expires: expiry,
};
const result = await this.$driver.getSignedUrlPromise('getObject', params);
return { signedUrl: result, raw: result };
} catch (e) {
throw handleError(e, location, this.$bucket);
}
}
/**
* Returns file's size and modification date.
*/
public async getStat(location: string): Promise<StatResponse> {
const params = { Key: location, Bucket: this.$bucket };
try {
const result = await this.$driver.headObject(params).promise();
return {
size: result.ContentLength as number,
modified: result.LastModified as Date,
raw: result,
};
} catch (e) {
throw handleError(e, location, this.$bucket);
}
}
/**
* Returns the stream for the given file.
*/
public getStream(location: string): NodeJS.ReadableStream {
const params = { Key: location, Bucket: this.$bucket };
return this.$driver.getObject(params).createReadStream();
}
/**
* Returns url for a given key.
*/
public getUrl(location: string): string {
const { href } = this.$driver.endpoint;
if (href.startsWith('https://s3.amazonaws')) {
return `https://${this.$bucket}.s3.amazonaws.com/${location}`;
}
return `${href}${this.$bucket}/${location}`;
}
/**
* Moves file from one location to another. This
* method will call `copy` and `delete` under
* the hood.
*/
public async move(src: string, dest: string): Promise<Response> {
await this.copy(src, dest);
await this.delete(src);
return { raw: undefined };
}
/**
* Creates a new file.
* This method will create missing directories on the fly.
*/
public async put(location: string, content: Buffer | NodeJS.ReadableStream | string): Promise<Response> {
const params = { Key: location, Body: content, Bucket: this.$bucket };
try {
const result = await this.$driver.upload(params).promise();
return { raw: result };
} catch (e) {
throw handleError(e, location, this.$bucket);
}
}
/**
* Iterate over all files in the bucket.
*/
public async *flatList(prefix = ''): AsyncIterable<FileListResponse> {
let continuationToken: string | undefined;
do {
try {
const response = await this.$driver
.listObjectsV2({
Bucket: this.$bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
MaxKeys: 1000,
})
.promise();
continuationToken = response.NextContinuationToken;
for (const file of response.Contents as ObjectList) {
yield {
raw: file,
path: file.Key as string,
};
}
} catch (e) {
throw handleError(e, prefix, this.$bucket);
}
} while (continuationToken);
}
}
export interface AmazonWebServicesS3StorageConfig extends ClientConfiguration {
key: string;
secret: string;
bucket: string;
}

View File

@@ -0,0 +1 @@
export * from './AmazonWebServicesS3Storage';

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"lib": ["es2018"],
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "tests", "dist"]
}

View File

@@ -0,0 +1,45 @@
{
"name": "@directus/drive",
"version": "1.0.0",
"description": "Flexible and Fluent way to manage storage in Node.js.",
"license": "MIT",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"storage",
"filesystem",
"file",
"aws",
"s3",
"azure",
"promise",
"async",
"spaces",
"google",
"cloud",
"directus"
],
"contributors": [
"Rijk van Zanten <rijkvanzanten@me.com>"
],
"files": [
"dist"
],
"scripts": {
"build": "tsc -b tsconfig.json"
},
"dependencies": {
"fs-extra": "^9.0.0",
"node-exceptions": "^4.0.1"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/directus/directus.git"
},
"bugs": {
"url": "https://github.com/directus/directus/issues"
}
}

8
packages/drive/readme.md Normal file
View File

@@ -0,0 +1,8 @@
# @directus/drive
Object storage abstraction layer.
## Credits
This package started life as a fork of [`@slynova/flydrive`](https://github.com/slynova-org/flydrive) by Robin Grundvåg
<robgru52@gmail.com>.

View File

@@ -0,0 +1,232 @@
import * as fse from 'fs-extra';
import { promises as fs } from 'fs';
import { dirname, join, resolve, relative, sep } from 'path';
import Storage from './Storage';
import { isReadableStream, pipeline } from './utils';
import { FileNotFound, UnknownException, PermissionMissing } from './exceptions';
import { Response, ExistsResponse, ContentResponse, StatResponse, FileListResponse, DeleteResponse } from './types';
function handleError(err: Error & { code: string; path?: string }, location: string): Error {
switch (err.code) {
case 'ENOENT':
return new FileNotFound(err, location);
case 'EPERM':
return new PermissionMissing(err, location);
default:
return new UnknownException(err, err.code, location);
}
}
export class LocalFileSystemStorage extends Storage {
private $root: string;
constructor(config: LocalFileSystemStorageConfig) {
super();
this.$root = resolve(config.root);
}
/**
* Returns full path relative to the storage's root directory.
*/
private _fullPath(relativePath: string): string {
return join(this.$root, join(sep, relativePath));
}
/**
* Appends content to a file.
*/
public async append(location: string, content: Buffer | string): Promise<Response> {
try {
const result = await fse.appendFile(this._fullPath(location), content);
return { raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Copy a file to a location.
*/
public async copy(src: string, dest: string): Promise<Response> {
try {
const result = await fse.copy(this._fullPath(src), this._fullPath(dest));
return { raw: result };
} catch (e) {
throw handleError(e, `${src} -> ${dest}`);
}
}
/**
* Delete existing file.
*/
public async delete(location: string): Promise<DeleteResponse> {
try {
const result = await fse.unlink(this._fullPath(location));
return { raw: result, wasDeleted: true };
} catch (e) {
e = handleError(e, location);
if (e instanceof FileNotFound) {
return { raw: undefined, wasDeleted: false };
}
throw e;
}
}
/**
* Returns the driver.
*/
public driver(): typeof fse {
return fse;
}
/**
* Determines if a file or folder already exists.
*/
public async exists(location: string): Promise<ExistsResponse> {
try {
const result = await fse.pathExists(this._fullPath(location));
return { exists: result, raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns the file contents as string.
*/
public async get(location: string, encoding = 'utf-8'): Promise<ContentResponse<string>> {
try {
const result = await fse.readFile(this._fullPath(location), encoding);
return { content: result, raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns the file contents as Buffer.
*/
public async getBuffer(location: string): Promise<ContentResponse<Buffer>> {
try {
const result = await fse.readFile(this._fullPath(location));
return { content: result, raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns file size in bytes.
*/
public async getStat(location: string): Promise<StatResponse> {
try {
const stat = await fse.stat(this._fullPath(location));
return {
size: stat.size,
modified: stat.mtime,
raw: stat,
};
} catch (e) {
throw handleError(e, location);
}
}
/**
* Returns a read stream for a file location.
*/
public getStream(location: string): NodeJS.ReadableStream {
return fse.createReadStream(this._fullPath(location));
}
/**
* Move file to a new location.
*/
public async move(src: string, dest: string): Promise<Response> {
try {
const result = await fse.move(this._fullPath(src), this._fullPath(dest));
return { raw: result };
} catch (e) {
throw handleError(e, `${src} -> ${dest}`);
}
}
/**
* Prepends content to a file.
*/
public async prepend(location: string, content: Buffer | string): Promise<Response> {
try {
const { content: actualContent } = await this.get(location, 'utf-8');
return this.put(location, `${content}${actualContent}`);
} catch (e) {
if (e instanceof FileNotFound) {
return this.put(location, content);
}
throw e;
}
}
/**
* Creates a new file.
* This method will create missing directories on the fly.
*/
public async put(location: string, content: Buffer | NodeJS.ReadableStream | string): Promise<Response> {
const fullPath = this._fullPath(location);
try {
if (isReadableStream(content)) {
const dir = dirname(fullPath);
await fse.ensureDir(dir);
const ws = fse.createWriteStream(fullPath);
await pipeline(content, ws);
return { raw: undefined };
}
const result = await fse.outputFile(fullPath, content);
return { raw: result };
} catch (e) {
throw handleError(e, location);
}
}
/**
* List files with a given prefix.
*/
public flatList(prefix = ''): AsyncIterable<FileListResponse> {
const fullPrefix = this._fullPath(prefix);
return this._flatDirIterator(fullPrefix, prefix);
}
private async *_flatDirIterator(prefix: string, originalPrefix: string): AsyncIterable<FileListResponse> {
const prefixDirectory = prefix[prefix.length - 1] === sep ? prefix : dirname(prefix);
try {
const dir = await fs.opendir(prefixDirectory);
for await (const file of dir) {
const fileName = join(prefixDirectory, file.name);
if (fileName.startsWith(prefix)) {
if (file.isDirectory()) {
yield* this._flatDirIterator(join(fileName, sep), originalPrefix);
} else if (file.isFile()) {
const path = relative(this.$root, fileName);
yield {
raw: null,
path,
};
}
}
}
} catch (e) {
if (e.code !== 'ENOENT') {
throw handleError(e, originalPrefix);
}
}
}
}
export type LocalFileSystemStorageConfig = {
root: string;
};

View File

@@ -0,0 +1,154 @@
import { MethodNotSupported } from './exceptions';
import {
Response,
SignedUrlResponse,
ContentResponse,
ExistsResponse,
SignedUrlOptions,
StatResponse,
FileListResponse,
DeleteResponse,
} from './types';
export default abstract class Storage {
/**
* Appends content to a file.
*
* Supported drivers: "local"
*/
append(location: string, content: Buffer | string): Promise<Response> {
throw new MethodNotSupported('append', this.constructor.name);
}
/**
* Copy a file to a location.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
copy(src: string, dest: string): Promise<Response> {
throw new MethodNotSupported('copy', this.constructor.name);
}
/**
* Delete existing file.
* The value returned by this method will have a `wasDeleted` property that
* can be either a boolean (`true` if a file was deleted, `false` if there was
* no file to delete) or `null` (if no information about the file is available).
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
delete(location: string): Promise<DeleteResponse> {
throw new MethodNotSupported('delete', this.constructor.name);
}
/**
* Returns the driver.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
public driver(): unknown {
throw new MethodNotSupported('driver', this.constructor.name);
}
/**
* Determines if a file or folder already exists.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
exists(location: string): Promise<ExistsResponse> {
throw new MethodNotSupported('exists', this.constructor.name);
}
/**
* Returns the file contents as a string.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
get(location: string, encoding?: string): Promise<ContentResponse<string>> {
throw new MethodNotSupported('get', this.constructor.name);
}
/**
* Returns the file contents as a Buffer.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
getBuffer(location: string): Promise<ContentResponse<Buffer>> {
throw new MethodNotSupported('getBuffer', this.constructor.name);
}
/**
* Returns signed url for an existing file.
*
* Supported drivers: "s3", "gcs", "azure"
*/
getSignedUrl(location: string, options?: SignedUrlOptions): Promise<SignedUrlResponse> {
throw new MethodNotSupported('getSignedUrl', this.constructor.name);
}
/**
* Returns file's size and modification date.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
getStat(location: string): Promise<StatResponse> {
throw new MethodNotSupported('getStat', this.constructor.name);
}
/**
* Returns the stream for the given file.
*
* Supported drivers: "local", "s3", "gcs"
*/
getStream(location: string): NodeJS.ReadableStream {
throw new MethodNotSupported('getStream', this.constructor.name);
}
/**
* Returns url for a given key. Note this method doesn't
* validates the existence of file or it's visibility
* status.
*
* Supported drivers: "s3", "gcs", "azure"
*/
getUrl(location: string): string {
throw new MethodNotSupported('getUrl', this.constructor.name);
}
/**
* Move file to a new location.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
move(src: string, dest: string): Promise<Response> {
throw new MethodNotSupported('move', this.constructor.name);
}
/**
* Creates a new file.
* This method will create missing directories on the fly.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
put(location: string, content: Buffer | NodeJS.ReadableStream | string): Promise<Response> {
throw new MethodNotSupported('put', this.constructor.name);
}
/**
* Prepends content to a file.
*
* Supported drivers: "local"
*/
prepend(location: string, content: Buffer | string): Promise<Response> {
throw new MethodNotSupported('prepend', this.constructor.name);
}
/**
* List files with a given prefix.
*
* Supported drivers: "local", "s3", "gcs", "azure"
*/
flatList(prefix?: string): AsyncIterable<FileListResponse> {
throw new MethodNotSupported('flatList', this.constructor.name);
}
}

View File

@@ -0,0 +1,109 @@
import { LocalFileSystemStorage } from './LocalFileSystemStorage';
import Storage from './Storage';
import { InvalidConfig, DriverNotSupported } from './exceptions';
import { StorageManagerConfig, StorageManagerDiskConfig, StorageManagerSingleDiskConfig } from './types';
interface StorageConstructor<T extends Storage = Storage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new (...args: any[]): T;
}
export default class StorageManager {
/**
* Default disk.
*/
private defaultDisk: string | undefined;
/**
* Configured disks.
*/
private disksConfig: StorageManagerDiskConfig;
/**
* Instantiated disks.
*/
private _disks: Map<string, Storage> = new Map();
/**
* List of available drivers.
*/
private _drivers: Map<string, StorageConstructor<Storage>> = new Map();
constructor(config: StorageManagerConfig) {
this.defaultDisk = config.default;
this.disksConfig = config.disks || {};
this.registerDriver('local', LocalFileSystemStorage);
}
/**
* Get the instantiated disks
*/
getDisks(): Map<string, Storage> {
return this._disks;
}
/**
* Get the registered drivers
*/
getDrivers(): Map<string, StorageConstructor<Storage>> {
return this._drivers;
}
/**
* Get a disk instance.
*/
disk<T extends Storage = Storage>(name?: string): T {
name = name || this.defaultDisk;
/**
* No name is defined and neither there
* are any defaults.
*/
if (!name) {
throw InvalidConfig.missingDiskName();
}
if (this._disks.has(name)) {
return this._disks.get(name) as T;
}
const diskConfig = this.disksConfig[name];
/**
* Configuration for the defined disk is missing
*/
if (!diskConfig) {
throw InvalidConfig.missingDiskConfig(name);
}
/**
* There is no driver defined on disk configuration
*/
if (!diskConfig.driver) {
throw InvalidConfig.missingDiskDriver(name);
}
const Driver = this._drivers.get(diskConfig.driver);
if (!Driver) {
throw DriverNotSupported.driver(diskConfig.driver);
}
const disk = new Driver(diskConfig.config);
this._disks.set(name, disk);
return disk as T;
}
addDisk(name: string, config: StorageManagerSingleDiskConfig): void {
if (this.disksConfig[name]) {
throw InvalidConfig.duplicateDiskName(name);
}
this.disksConfig[name] = config;
}
/**
* Register a custom driver.
*/
public registerDriver<T extends Storage>(name: string, driver: StorageConstructor<T>): void {
this._drivers.set(name, driver);
}
}

View File

@@ -0,0 +1,9 @@
import { RuntimeException } from 'node-exceptions';
export class AuthorizationRequired extends RuntimeException {
raw: Error;
constructor(err: Error, path: string) {
super(`Unauthorized to access file ${path}\n${err.message}`, 500, 'E_AUTHORIZATION_REQUIRED');
this.raw = err;
}
}

View File

@@ -0,0 +1,13 @@
import { RuntimeException } from 'node-exceptions';
export class DriverNotSupported extends RuntimeException {
public driver!: string;
public static driver(name: string): DriverNotSupported {
const exception = new this(`Driver ${name} is not supported`, 400);
exception.driver = name;
return exception;
}
}

View File

@@ -0,0 +1,9 @@
import { RuntimeException } from 'node-exceptions';
export class FileNotFound extends RuntimeException {
raw: Error;
constructor(err: Error, path: string) {
super(`The file ${path} doesn't exist\n${err.message}`, 500, 'E_FILE_NOT_FOUND');
this.raw = err;
}
}

View File

@@ -0,0 +1,19 @@
import { RuntimeException } from 'node-exceptions';
export class InvalidConfig extends RuntimeException {
public static missingDiskName(): InvalidConfig {
return new this('Make sure to define a default disk name inside config file', 500, 'E_INVALID_CONFIG');
}
public static missingDiskConfig(name: string): InvalidConfig {
return new this(`Make sure to define config for ${name} disk`, 500, 'E_INVALID_CONFIG');
}
public static missingDiskDriver(name: string): InvalidConfig {
return new this(`Make sure to define driver for ${name} disk`, 500, 'E_INVALID_CONFIG');
}
public static duplicateDiskName(name: string): InvalidConfig {
return new this(`A disk named ${name} is already defined`, 500, 'E_INVALID_CONFIG');
}
}

View File

@@ -0,0 +1,7 @@
import { RuntimeException } from 'node-exceptions';
export class MethodNotSupported extends RuntimeException {
constructor(name: string, driver: string) {
super(`Method ${name} is not supported for the driver ${driver}`, 500, 'E_METHOD_NOT_SUPPORTED');
}
}

View File

@@ -0,0 +1,9 @@
import { RuntimeException } from 'node-exceptions';
export class NoSuchBucket extends RuntimeException {
raw: Error;
constructor(err: Error, bucket: string) {
super(`The bucket ${bucket} doesn't exist\n${err.message}`, 500, 'E_NO_SUCH_BUCKET');
this.raw = err;
}
}

View File

@@ -0,0 +1,9 @@
import { RuntimeException } from 'node-exceptions';
export class PermissionMissing extends RuntimeException {
raw: Error;
constructor(err: Error, path: string) {
super(`Missing permission for file ${path}\n${err.message}`, 500, 'E_PERMISSION_MISSING');
this.raw = err;
}
}

View File

@@ -0,0 +1,17 @@
import { RuntimeException } from 'node-exceptions';
export class UnknownException extends RuntimeException {
raw: Error;
constructor(err: Error, errorCode: string, path: string) {
super(
`An unknown error happened with the file ${path}.
Error code: ${errorCode}
Original stack:
${err.stack}`,
500,
'E_UNKNOWN'
);
this.raw = err;
}
}

View File

@@ -0,0 +1,9 @@
import { RuntimeException } from 'node-exceptions';
export class WrongKeyPath extends RuntimeException {
raw: Error;
constructor(err: Error, path: string) {
super(`The key path does not exist: ${path}\n${err.message}`, 500, 'E_WRONG_KEY_PATH');
this.raw = err;
}
}

View File

@@ -0,0 +1,9 @@
export * from './AuthorizationRequired';
export * from './DriverNotSupported';
export * from './FileNotFound';
export * from './InvalidConfig';
export * from './MethodNotSupported';
export * from './NoSuchBucket';
export * from './PermissionMissing';
export * from './UnknownException';
export * from './WrongKeyPath';

View File

@@ -0,0 +1,7 @@
export { default as Storage } from './Storage';
export { default as StorageManager } from './StorageManager';
export { LocalFileSystemStorage } from './LocalFileSystemStorage';
export * from './exceptions';
export * from './utils';
export * from './types';

View File

@@ -0,0 +1,63 @@
import { LocalFileSystemStorageConfig } from './LocalFileSystemStorage';
export type { LocalFileSystemStorageConfig };
export type StorageManagerSingleDiskConfig =
| {
driver: 'local';
config: LocalFileSystemStorageConfig;
}
| {
driver: string;
config: unknown;
};
export interface StorageManagerDiskConfig {
[key: string]: StorageManagerSingleDiskConfig;
}
export interface StorageManagerConfig {
/**
* The default disk returned by `disk()`.
*/
default?: string;
disks?: StorageManagerDiskConfig;
}
export interface Response {
raw: unknown;
}
export interface ExistsResponse extends Response {
exists: boolean;
}
export interface ContentResponse<ContentType> extends Response {
content: ContentType;
}
export interface SignedUrlOptions {
/**
* Expiration time of the URL.
* It should be a number of seconds from now.
* @default `900` (15 minutes)
*/
expiry?: number;
}
export interface SignedUrlResponse extends Response {
signedUrl: string;
}
export interface StatResponse extends Response {
size: number;
modified: Date;
}
export interface FileListResponse extends Response {
path: string;
}
export interface DeleteResponse extends Response {
wasDeleted: boolean | null;
}

View File

@@ -0,0 +1,20 @@
import { promisify } from 'util';
import { pipeline as nodePipeline } from 'stream';
/**
* Returns a boolean indication if stream param
* is a readable stream or not.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isReadableStream(stream: any): stream is NodeJS.ReadableStream {
return (
stream !== null &&
typeof stream === 'object' &&
typeof stream.pipe === 'function' &&
typeof stream._read === 'function' &&
typeof stream._readableState === 'object' &&
stream.readable !== false
);
}
export const pipeline = promisify(nodePipeline);

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"sourceMap": true,
"lib": ["es2018"],
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "tests", "dist"]
}