mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
1935
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -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": {
|
||||
|
||||
51
packages/drive-azure/package.json
Normal file
51
packages/drive-azure/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/drive-azure/readme.md
Normal file
3
packages/drive-azure/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @directus/drive-azure
|
||||
|
||||
Azure storage layer for `@directus/drive`
|
||||
215
packages/drive-azure/src/AzureBlobWebServices.ts
Normal file
215
packages/drive-azure/src/AzureBlobWebServices.ts
Normal 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;
|
||||
}
|
||||
1
packages/drive-azure/src/index.ts
Normal file
1
packages/drive-azure/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './AzureBlobWebServices';
|
||||
16
packages/drive-azure/tsconfig.json
Normal file
16
packages/drive-azure/tsconfig.json
Normal 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"]
|
||||
}
|
||||
49
packages/drive-gcs/package.json
Normal file
49
packages/drive-gcs/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/drive-gcs/readme.md
Normal file
3
packages/drive-gcs/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @directus/drive-gcs
|
||||
|
||||
Google Cloud Storage storage layer for `@directus/drive`
|
||||
251
packages/drive-gcs/src/GoogleCloudStorage.ts
Normal file
251
packages/drive-gcs/src/GoogleCloudStorage.ts
Normal 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;
|
||||
}
|
||||
1
packages/drive-gcs/src/index.ts
Normal file
1
packages/drive-gcs/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './GoogleCloudStorage';
|
||||
16
packages/drive-gcs/tsconfig.json
Normal file
16
packages/drive-gcs/tsconfig.json
Normal 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"]
|
||||
}
|
||||
51
packages/drive-s3/package.json
Normal file
51
packages/drive-s3/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/drive-s3/readme.md
Normal file
3
packages/drive-s3/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @directus/drive-s3
|
||||
|
||||
S3 storage layer for `@directus/drive`
|
||||
256
packages/drive-s3/src/AmazonWebServicesS3Storage.ts
Normal file
256
packages/drive-s3/src/AmazonWebServicesS3Storage.ts
Normal 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;
|
||||
}
|
||||
1
packages/drive-s3/src/index.ts
Normal file
1
packages/drive-s3/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './AmazonWebServicesS3Storage';
|
||||
16
packages/drive-s3/tsconfig.json
Normal file
16
packages/drive-s3/tsconfig.json
Normal 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"]
|
||||
}
|
||||
45
packages/drive/package.json
Normal file
45
packages/drive/package.json
Normal 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
8
packages/drive/readme.md
Normal 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>.
|
||||
232
packages/drive/src/LocalFileSystemStorage.ts
Normal file
232
packages/drive/src/LocalFileSystemStorage.ts
Normal 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;
|
||||
};
|
||||
154
packages/drive/src/Storage.ts
Normal file
154
packages/drive/src/Storage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
109
packages/drive/src/StorageManager.ts
Normal file
109
packages/drive/src/StorageManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/AuthorizationRequired.ts
Normal file
9
packages/drive/src/exceptions/AuthorizationRequired.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
packages/drive/src/exceptions/DriverNotSupported.ts
Normal file
13
packages/drive/src/exceptions/DriverNotSupported.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/FileNotFound.ts
Normal file
9
packages/drive/src/exceptions/FileNotFound.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
packages/drive/src/exceptions/InvalidConfig.ts
Normal file
19
packages/drive/src/exceptions/InvalidConfig.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
7
packages/drive/src/exceptions/MethodNotSupported.ts
Normal file
7
packages/drive/src/exceptions/MethodNotSupported.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/NoSuchBucket.ts
Normal file
9
packages/drive/src/exceptions/NoSuchBucket.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/PermissionMissing.ts
Normal file
9
packages/drive/src/exceptions/PermissionMissing.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
packages/drive/src/exceptions/UnknownException.ts
Normal file
17
packages/drive/src/exceptions/UnknownException.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/WrongKeyPath.ts
Normal file
9
packages/drive/src/exceptions/WrongKeyPath.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
packages/drive/src/exceptions/index.ts
Normal file
9
packages/drive/src/exceptions/index.ts
Normal 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';
|
||||
7
packages/drive/src/index.ts
Normal file
7
packages/drive/src/index.ts
Normal 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';
|
||||
63
packages/drive/src/types.ts
Normal file
63
packages/drive/src/types.ts
Normal 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;
|
||||
}
|
||||
20
packages/drive/src/utils.ts
Normal file
20
packages/drive/src/utils.ts
Normal 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);
|
||||
16
packages/drive/tsconfig.json
Normal file
16
packages/drive/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user