@nephele/adapter-s3
Version:
S3 (or compatible) object storage adapter for the Nephele WebDAV server.
959 lines (806 loc) • 24.7 kB
text/typescript
import { Readable } from 'node:stream';
import path from 'node:path';
import {
ListObjectsV2Command,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
CopyObjectCommand,
NoSuchKey,
NotFound,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import createDebug from 'debug';
import type { Resource as ResourceInterface, User } from 'nephele';
import {
BadGatewayError,
ForbiddenError,
MethodNotSupportedError,
ResourceExistsError,
ResourceNotFoundError,
ResourceTreeNotCompleteError,
} from 'nephele';
import type Adapter from './Adapter.js';
import Properties from './Properties.js';
import Lock from './Lock.js';
const debug = createDebug('nephele:adapter-s3');
export type MetaStorage = {
props?: {
[name: string]: any;
};
locks?: {
[token: string]: {
username: string;
date: number;
timeout: number;
scope: 'exclusive' | 'shared';
depth: '0' | 'infinity';
provisional: boolean;
owner: any;
};
};
};
export default class Resource implements ResourceInterface {
adapter: Adapter;
baseUrl: URL;
path: string;
key: string;
/** Metadata cache. */
private meta: MetaStorage | undefined = undefined;
/** Whether this is a brand new collection. */
private createCollection: boolean | undefined = undefined;
/** Whether this is a collection. */
private collection: boolean | undefined = undefined;
/** Whether this resource is in the storage backend. */
private inStorage: boolean | undefined = undefined;
private etag: string | undefined = undefined;
private size: number | undefined = undefined;
private contentType: string | undefined = undefined;
private lastModified: Date | undefined = undefined;
/** Resolves when the resource's metadata is ready to be read/written. */
private metaReadyPromise = Promise.resolve();
constructor({
adapter,
baseUrl,
path: pathname,
exists,
collection,
}: {
adapter: Adapter;
baseUrl: URL;
path: string;
exists?: boolean;
collection?: boolean;
}) {
this.adapter = adapter;
this.baseUrl = baseUrl;
this.path = pathname;
this.key = this.adapter.relativePathToKey(this.path);
if (exists === false) {
this.inStorage = false;
}
if (collection) {
this.createCollection = !exists;
this.collection = true;
}
}
async getLocks() {
const meta = await this.getMetadata();
if (meta.locks == null) {
return [];
}
return Object.entries(meta.locks).map(([token, entry]) => {
const lock = new Lock({ resource: this, username: entry.username });
lock.token = token;
lock.date = new Date(entry.date);
lock.timeout = entry.timeout;
lock.scope = entry.scope;
lock.depth = entry.depth;
lock.provisional = entry.provisional;
lock.owner = entry.owner;
return lock;
});
}
async getLocksByUser(user: User) {
const meta = await this.getMetadata();
if (meta.locks == null) {
return [];
}
return Object.entries(meta.locks)
.filter(([_token, entry]) => user.username === entry.username)
.map(([token, entry]) => {
const lock = new Lock({ resource: this, username: user.username });
lock.token = token;
lock.date = new Date(entry.date);
lock.timeout = entry.timeout;
lock.scope = entry.scope;
lock.depth = entry.depth;
lock.provisional = entry.provisional;
lock.owner = entry.owner;
return lock;
});
}
async createLockForUser(user: User) {
return new Lock({ resource: this, username: user.username });
}
async getProperties() {
return new Properties({ resource: this });
}
async getStream(range?: { start: number; end: number }) {
if (await this.isCollection()) {
return Readable.from([]);
}
try {
debug('GetObjectCommand', this.key);
const command = new GetObjectCommand({
Bucket: this.adapter.bucket,
Key: this.key,
...(range
? {
Range: `bytes=${range.start}-${range.end}`,
}
: {}),
});
const data = await this.adapter.s3.send(command);
const body = data.Body;
if (body == null) {
throw new Error('Object not returned by blob store.');
}
return body as Readable;
} catch (e: any) {
if (e instanceof NoSuchKey || e instanceof NotFound) {
throw new ResourceNotFoundError();
}
throw e;
}
}
async setStream(input: Readable, _user: User, mediaType?: string) {
if (!(await this.resourceTreeExists())) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
if (await this.isCollection()) {
throw new MethodNotSupportedError(
'This resource is an existing collection.',
);
}
const meta = await this.getMetadata();
let resolve: () => void = () => {};
let reject: (reason?: any) => void = () => {};
this.metaReadyPromise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
debug('Upload', this.key, mediaType);
const parallelUpload = new Upload({
client: this.adapter.s3,
params: {
Bucket: this.adapter.bucket,
Key: this.key,
ContentType: mediaType,
Body: input,
Metadata: this.translateMetadata(meta),
},
queueSize: this.adapter.uploadQueueSize,
leavePartsOnError: true,
});
try {
const response = await parallelUpload.done();
this.etag = response.ETag;
} catch (e: any) {
reject(e);
throw e;
}
this.inStorage = true;
resolve();
try {
await this.deleteEmptyDir(path.dirname(this.key));
} catch (e: any) {
// Ignore errors trying to delete potentially non-existent file.
}
}
async create(_user: User) {
if (await this.exists()) {
throw new ResourceExistsError('A resource already exists here.');
}
if (!(await this.resourceTreeExists())) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
let command: PutObjectCommand;
if (this.createCollection) {
const emptyKey = `${this.key.replace(/\/?$/, () => '/')}.nepheleempty`;
debug('PutObjectCommand', emptyKey);
command = new PutObjectCommand({
Bucket: this.adapter.bucket,
Key: emptyKey,
Body: Buffer.from([]),
});
} else {
debug('PutObjectCommand', this.key);
command = new PutObjectCommand({
Bucket: this.adapter.bucket,
Key: this.key,
Body: Buffer.from([]),
});
}
const response = await this.adapter.s3.send(command);
this.etag = response.ETag;
if (!this.createCollection) {
this.inStorage = true;
}
try {
await this.deleteEmptyDir(path.dirname(this.key));
} catch (e: any) {
// Ignore errors trying to delete potentially non-existent file.
}
}
async delete(_user: User) {
if (!(await this.exists())) {
throw new ResourceNotFoundError("This resource couldn't be found.");
}
if ((await this.isCollection()) && (await this.isEmpty())) {
await this.deleteEmptyDir(this.key);
} else {
debug('DeleteObjectCommand', this.key);
const command = new DeleteObjectCommand({
Bucket: this.adapter.bucket,
Key: this.key,
});
await this.adapter.s3.send(command);
try {
this.createEmptyDir(path.dirname(this.key));
} catch (e: any) {
// Ignore errors trying to recreate empty dir file.
}
}
this.etag = undefined;
this.inStorage = false;
}
async copy(destination: URL, baseUrl: URL, user: User) {
const destinationPath = this.adapter.urlToRelativePath(
destination,
baseUrl,
);
if (destinationPath == null) {
throw new BadGatewayError(
'The destination URL is not under the namespace of this server.',
);
}
if (
this.path === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(
this.path.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
),
))
) {
throw new ForbiddenError(
'The destination cannot be the same as or contained within the source.',
);
}
const destinationKey = this.adapter.relativePathToKey(destinationPath);
if (!(await this.resourceTreeExists(destinationKey))) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
let meta = await this.getMetadata();
meta.locks = {};
if (await this.isCollection()) {
try {
const destinationResource = await this.adapter.getResource(
destination,
this.baseUrl,
);
if (await destinationResource.isCollection()) {
if (!(await destinationResource.isEmpty())) {
throw new Error('Directory not empty.');
}
try {
await destinationResource.delete(user);
} catch (e: any) {
// Ignore errors deleting possible non-existent file.
}
} else {
await destinationResource.delete(user);
}
} catch (e: any) {
// Ignore errors stat-ing a possible non-existent directory and deleting
// a possibly non-empty directory.
}
const metadata = this.translateMetadata(meta);
if (await this.existsInStorage()) {
debug('CopyObjectCommand', destinationKey);
const command = new CopyObjectCommand({
Bucket: this.adapter.bucket,
CopySource: `${this.adapter.bucket}/${this.key}`,
Key: destinationKey,
Metadata: metadata,
MetadataDirective: 'REPLACE',
});
await this.adapter.s3.send(command);
} else {
const emptyKey = `${destinationKey.replace(
/\/?$/,
() => '/',
)}.nepheleempty`;
debug('PutObjectCommand', emptyKey);
const command = new PutObjectCommand({
Bucket: this.adapter.bucket,
Key: emptyKey,
Metadata: { ...metadata },
Body: Buffer.from([]),
});
await this.adapter.s3.send(command);
}
} else {
const metadata = this.translateMetadata(meta);
debug('CopyObjectCommand', destinationKey);
const command = new CopyObjectCommand({
Bucket: this.adapter.bucket,
CopySource: `${this.adapter.bucket}/${this.key}`,
Key: destinationKey,
Metadata: metadata,
MetadataDirective: 'REPLACE',
});
await this.adapter.s3.send(command);
}
try {
await this.deleteEmptyDir(path.dirname(destinationKey));
} catch (e: any) {
// Ignore errors trying to delete potentially non-existent file.
}
}
async move(destination: URL, baseUrl: URL, user: User) {
if (await this.isCollection()) {
throw new Error('Move called on a collection resource.');
}
const destinationPath = this.adapter.urlToRelativePath(
destination,
baseUrl,
);
if (destinationPath == null) {
throw new BadGatewayError(
'The destination URL is not under the namespace of this server.',
);
}
if (
this.path === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(
this.path.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
),
))
) {
throw new ForbiddenError(
'The destination cannot be the same as or contained within the source.',
);
}
const destinationKey = this.adapter.relativePathToKey(destinationPath);
if (!(await this.resourceTreeExists(destinationKey))) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
try {
const destinationResource = await this.adapter.getResource(
destination,
baseUrl,
);
if (
(await destinationResource.isCollection()) &&
!(await destinationResource.isEmpty())
) {
throw new ForbiddenError(
'The destination cannot be an existing non-empty directory.',
);
}
} catch (e: any) {
if (!(e instanceof ResourceNotFoundError)) {
throw e;
}
}
const meta = await this.getMetadata();
meta.locks = {};
const metadata = this.translateMetadata(meta);
debug('CopyObjectCommand', destinationKey);
const command = new CopyObjectCommand({
Bucket: this.adapter.bucket,
CopySource: `${this.adapter.bucket}/${this.key}`,
Key: destinationKey,
Metadata: metadata,
MetadataDirective: 'REPLACE',
});
await this.adapter.s3.send(command);
await this.delete(user);
try {
await this.deleteEmptyDir(path.dirname(destinationKey));
} catch (e: any) {
// Ignore errors trying to delete potentially non-existent file.
}
}
async getLength() {
if (await this.isCollection()) {
return 0;
}
if (this.size != null) {
return this.size;
}
await this.getMetadata();
return this.size ?? 0;
}
async getEtag() {
if (this.etag != null) {
return this.etag;
}
if (!(await this.exists())) {
throw new ResourceNotFoundError();
}
return this.etag ?? 'default-etag';
}
async getMediaType() {
if (await this.isCollection()) {
return null;
}
if (this.contentType != null) {
return this.contentType;
}
await this.getMetadata();
return this.contentType ?? null;
}
async getLastModified() {
if (this.lastModified != null) {
return this.lastModified;
}
if (!(await this.exists())) {
throw new ResourceNotFoundError();
}
return this.lastModified ?? null;
}
async getCanonicalName() {
return path.basename(this.path);
}
async getCanonicalPath() {
if (await this.isCollection()) {
return this.path.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
);
}
return this.path;
}
async getCanonicalUrl() {
return new URL(
(await this.getCanonicalPath())
.split(path.sep)
.map(encodeURIComponent)
.join('/')
.replace(/^\//, () => ''),
this.baseUrl,
);
}
async *listKeys(prefix?: string, maxKeys?: number) {
const command = new ListObjectsV2Command({
Bucket: this.adapter.bucket,
MaxKeys: maxKeys,
Delimiter: '/',
...(prefix && prefix !== '/'
? {
Prefix: prefix.replace(/\/?$/, () => '/'),
}
: {}),
});
let isTruncated: boolean | undefined = true;
while (isTruncated) {
const { CommonPrefixes, Contents, IsTruncated, NextContinuationToken } =
await this.adapter.s3.send(command);
if (CommonPrefixes == null && Contents == null) {
break;
}
if (CommonPrefixes != null) {
for (let prefix of CommonPrefixes) {
if (prefix.Prefix != null && prefix.Prefix !== prefix) {
yield { key: `${prefix.Prefix}`, size: 0, type: 'collection' };
}
}
}
if (Contents != null) {
for (let content of Contents) {
if (content.Key !== prefix) {
yield {
key: `${content.Key}`,
size: content.Size || 0,
type: 'unknown',
};
}
}
}
isTruncated = IsTruncated;
command.input.ContinuationToken = NextContinuationToken;
}
}
async isCollection() {
if (this.collection != null) {
return this.collection;
}
if (this.createCollection || this.isRoot()) {
return true;
}
const keys = this.listKeys(
this.key.replace(/\/?$/, () => '/'),
1,
);
for await (let key of keys) {
if (key) {
this.collection = true;
return true;
}
}
const collection = await this.existsInStorage(
`${this.key.replace(/\/?$/, () => '/')}.nepheleempty`,
);
this.collection = collection;
return this.collection;
}
async isEmpty() {
if (this.createCollection) {
return true;
}
const keys = this.listKeys(
this.key.replace(/\/?$/, () => '/'),
1,
);
for await (let key of keys) {
if (key && key.key !== `${this.key}/.nepheleempty`) {
return false;
}
}
return true;
}
isRoot(key = this.key) {
return key === '' || key.replace(/\/?$/, () => '/') === '/';
}
async getInternalMembers(_user: User) {
if (!(await this.isCollection())) {
throw new MethodNotSupportedError('This is not a collection.');
}
const keys = this.listKeys(this.key.replace(/\/?$/, () => '/'));
const collections: { [k: string]: Resource } = {};
const resources: { [k: string]: Resource } = {};
for await (let key of keys) {
if (key.type === 'collection') {
collections[key.key] = new Resource({
path: this.adapter.keyToRelativePath(key.key),
baseUrl: this.baseUrl,
adapter: this.adapter,
exists: true,
collection: true,
});
} else {
resources[key.key] = new Resource({
path: this.adapter.keyToRelativePath(key.key),
baseUrl: this.baseUrl,
adapter: this.adapter,
exists: true,
collection: false,
});
}
}
// Check for an empty object and see if there are other objects in the dir.
const emptyKey = `${this.key.replace(/\/?$/, () => '/')}.nepheleempty`;
if (emptyKey in resources) {
delete resources[emptyKey];
if (Object.keys(collections).length || Object.keys(resources).length) {
// Delete the empty object for a collection resource that has children.
debug('DeleteObjectCommand', emptyKey);
const command = new DeleteObjectCommand({
Bucket: this.adapter.bucket,
Key: emptyKey,
});
await this.adapter.s3.send(command);
}
}
return [...Object.values(collections), ...Object.values(resources)];
}
async exists(key = this.key) {
if (this.isRoot(key) || (await this.existsInStorage(key))) {
return true;
}
// Check for resource under this one.
const keys = this.listKeys(
key.replace(/\/?$/, () => '/'),
1,
);
for await (let _key of keys) {
return true;
}
return false;
}
async existsInStorage(key = this.key) {
// Only check if it exists in storage.
if (this.isRoot(key)) {
return false;
}
if (key === this.key && this.inStorage != null) {
return this.inStorage;
}
await this.metaReadyPromise;
try {
debug('HeadObjectCommand', key);
const command = new HeadObjectCommand({
Bucket: this.adapter.bucket,
Key: key,
});
const response = await this.adapter.s3.send(command);
if (key === this.key) {
this.etag = response.ETag;
this.size = response.ContentLength;
this.contentType = response.ContentType;
this.lastModified = response.LastModified;
this.meta = {};
this.meta.props = JSON.parse(
response.Metadata?.['nephele-properties'] ?? '{}',
);
this.meta.locks = JSON.parse(
response.Metadata?.['nephele-locks'] ?? '{}',
);
}
} catch (e: any) {
if (e instanceof NoSuchKey || e instanceof NotFound) {
if (key === this.key) {
this.inStorage = false;
}
return false;
}
throw e;
}
if (key === this.key) {
this.inStorage = true;
}
return true;
}
async resourceTreeExists(key = this.key) {
// We're going to say that a resource tree always exists, since you can
// create "directories" in S3 just by adding an object with that key prefix.
return true;
// If we actually did want to check, here's how we'd do it.
// let pathname = this.adapter.keyToRelativePath(key);
// let dirname = path.dirname(pathname);
// try {
// while (dirname != '.') {
// const dirkey = this.adapter.relativePathToKey(dirname);
// debug('GetObjectAttributesCommand', dirkey);
// const command = new GetObjectAttributesCommand({
// Bucket: this.adapter.bucket,
// Key: dirkey,
// ObjectAttributes: [],
// });
// await this.adapter.s3.send(command);
// dirname = path.dirname(dirname);
// }
// } catch (e: any) {
// if (e instanceof NoSuchKey || e instanceof NotFound) {
// return false;
// }
// throw e;
// }
// return true;
}
async createEmptyDir(key: string) {
if (key === '' || key === '/' || key === '.') {
return;
}
const keys = this.listKeys(key, 1);
for await (let key of keys) {
if (key) {
return;
}
}
const emptyKey = `${key.replace(/\/?$/, () => '/')}.nepheleempty`;
debug('PutObjectCommand', emptyKey);
const command = new PutObjectCommand({
Bucket: this.adapter.bucket,
Key: emptyKey,
Body: Buffer.from([]),
});
await this.adapter.s3.send(command);
}
async deleteEmptyDir(key: string) {
if (key === '' || key === '/' || key === '.') {
return;
}
const emptyKey = `${key.replace(/\/?$/, () => '/')}.nepheleempty`;
debug('DeleteObjectCommand', emptyKey);
const command = new DeleteObjectCommand({
Bucket: this.adapter.bucket,
Key: emptyKey,
});
await this.adapter.s3.send(command);
}
async getMetadata(): Promise<MetaStorage> {
if (this.meta != null) {
return this.meta;
}
if (this.isRoot()) {
this.meta = {};
return this.meta;
}
this.meta = {};
await this.metaReadyPromise;
try {
debug('HeadObjectCommand', this.key);
const command = new HeadObjectCommand({
Bucket: this.adapter.bucket,
Key: this.key,
});
const response = await this.adapter.s3.send(command);
this.etag = response.ETag;
this.size = response.ContentLength;
this.contentType = response.ContentType;
this.lastModified = response.LastModified;
this.meta.props = JSON.parse(
response.Metadata?.['nephele-properties'] ?? '{}',
);
this.meta.locks = JSON.parse(
response.Metadata?.['nephele-locks'] ?? '{}',
);
this.inStorage = true;
} catch (e: any) {
if (!(e instanceof NotFound || e instanceof NoSuchKey)) {
this.inStorage = false;
throw e;
}
}
return this.meta;
}
/**
* Translate metadata into the format S3 expects.
*/
translateMetadata(meta: MetaStorage) {
const metadata: { [k: string]: string } = {};
const props = meta.props ?? {};
const locks = meta.locks ?? {};
metadata['nephele-properties'] = JSON.stringify(props);
metadata['nephele-locks'] = JSON.stringify(locks);
return metadata;
}
async saveMetadata(meta: MetaStorage) {
const metadata = this.translateMetadata(meta);
await this.metaReadyPromise;
if (this.inStorage === false) {
this.meta = meta;
return;
}
try {
// Changing metadata in S3 is accomplished by copying an object to its own
// key and updating the metadata during copy.
debug('CopyObjectCommand', this.key, metadata);
const command = new CopyObjectCommand({
Bucket: this.adapter.bucket,
CopySource: `${this.adapter.bucket}/${this.key}`,
Key: this.key,
Metadata: metadata,
MetadataDirective: 'REPLACE',
});
const response = await this.adapter.s3.send(command);
this.etag = response.CopyObjectResult?.ETag ?? this.etag;
this.lastModified =
response.CopyObjectResult?.LastModified ?? this.lastModified;
this.meta = meta;
} catch (e: any) {
if (e instanceof NoSuchKey || e instanceof NotFound) {
this.meta = meta;
} else {
throw e;
}
}
}
}