UNPKG

@nephele/adapter-s3

Version:

S3 (or compatible) object storage adapter for the Nephele WebDAV server.

654 lines 24.1 kB
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 { BadGatewayError, ForbiddenError, MethodNotSupportedError, ResourceExistsError, ResourceNotFoundError, ResourceTreeNotCompleteError, } from 'nephele'; import Properties from './Properties.js'; import Lock from './Lock.js'; const debug = createDebug('nephele:adapter-s3'); export default class Resource { constructor({ adapter, baseUrl, path: pathname, exists, collection, }) { this.meta = undefined; this.createCollection = undefined; this.collection = undefined; this.inStorage = undefined; this.etag = undefined; this.size = undefined; this.contentType = undefined; this.lastModified = undefined; this.metaReadyPromise = Promise.resolve(); 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) { 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) { return new Lock({ resource: this, username: user.username }); } async getProperties() { return new Properties({ resource: this }); } async getStream(range) { 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; } catch (e) { if (e instanceof NoSuchKey || e instanceof NotFound) { throw new ResourceNotFoundError(); } throw e; } } async setStream(input, _user, mediaType) { 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 = () => { }; let reject = () => { }; 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) { reject(e); throw e; } this.inStorage = true; resolve(); try { await this.deleteEmptyDir(path.dirname(this.key)); } catch (e) { } } async create(_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; 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) { } } async delete(_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) { } } this.etag = undefined; this.inStorage = false; } async copy(destination, baseUrl, 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) { } } else { await destinationResource.delete(user); } } catch (e) { } 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) { } } async move(destination, baseUrl, 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) { 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) { } } 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, maxKeys) { const command = new ListObjectsV2Command({ Bucket: this.adapter.bucket, MaxKeys: maxKeys, Delimiter: '/', ...(prefix && prefix !== '/' ? { Prefix: prefix.replace(/\/?$/, () => '/'), } : {}), }); let isTruncated = 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) { if (!(await this.isCollection())) { throw new MethodNotSupportedError('This is not a collection.'); } const keys = this.listKeys(this.key.replace(/\/?$/, () => '/')); const collections = {}; const resources = {}; 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, }); } } const emptyKey = `${this.key.replace(/\/?$/, () => '/')}.nepheleempty`; if (emptyKey in resources) { delete resources[emptyKey]; if (Object.keys(collections).length || Object.keys(resources).length) { 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; } const keys = this.listKeys(key.replace(/\/?$/, () => '/'), 1); for await (let _key of keys) { return true; } return false; } async existsInStorage(key = this.key) { 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) { 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) { return true; } async createEmptyDir(key) { 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) { 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() { 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) { if (!(e instanceof NotFound || e instanceof NoSuchKey)) { this.inStorage = false; throw e; } } return this.meta; } translateMetadata(meta) { const metadata = {}; 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) { const metadata = this.translateMetadata(meta); await this.metaReadyPromise; if (this.inStorage === false) { this.meta = meta; return; } try { 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) { if (e instanceof NoSuchKey || e instanceof NotFound) { this.meta = meta; } else { throw e; } } } } //# sourceMappingURL=Resource.js.map