@nephele/adapter-nymph
Version:
Nymph.js based deduping file adapter for the Nephele WebDAV server.
379 lines • 16.3 kB
JavaScript
import { Readable } from 'node:stream';
import fsp from 'node:fs/promises';
import { constants } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileTypeFromFile } from 'file-type';
import checkDiskSpace from 'check-disk-space';
import { BackPressureTransform } from '@sciactive/back-pressure-transform';
import { v4 as uuidv4 } from 'uuid';
import { TilmeldAccessLevels } from '@nymphjs/nymph';
import { User as NymphUser, enforceTilmeld } from '@nymphjs/tilmeld';
import { BadGatewayError, ForbiddenError, InternalServerError, MethodNotSupportedError, ResourceExistsError, ResourceNotFoundError, ResourceTreeNotCompleteError, UnauthorizedError, } from 'nephele';
import Properties from './Properties.js';
import Lock from './Lock.js';
import { EMPTY_HASH } from './constants.js';
export default class Resource {
constructor({ adapter, baseUrl, path, nymphResource, rootResource, }) {
this.adapter = adapter;
this.baseUrl = baseUrl;
this.path = path;
this.nymphResource = nymphResource;
this.rootResource = rootResource ?? nymphResource;
}
async getLocks() {
const nymphLocks = await this.adapter.nymph.getEntities({ class: this.adapter.NymphLock }, {
type: '&',
ref: ['resource', this.nymphResource],
});
return nymphLocks.map((nymphLock) => new Lock({ resource: this, nymphLock }));
}
async getLocksByUser(user) {
const nymphLocks = await this.adapter.nymph.getEntities({ class: this.adapter.NymphLock }, {
type: '&',
ref: ['resource', this.nymphResource],
equal: ['username', user.username],
});
return nymphLocks.map((nymphLock) => new Lock({ resource: this, nymphLock }));
}
async createLockForUser(user) {
const nymphLock = await this.adapter.NymphLock.factory();
nymphLock.username = user.username;
nymphLock.resource = this.nymphResource;
return new Lock({ resource: this, nymphLock });
}
async getProperties() {
return new Properties({ resource: this });
}
getBlobDirname(hash) {
const threeBytes = (hash ?? this.nymphResource?.hash ?? EMPTY_HASH).slice(0, 6);
const dirname = path.resolve(this.adapter.blobRoot, threeBytes.slice(0, 2), threeBytes.slice(2, 4), threeBytes.slice(4, 6));
return dirname;
}
async deleteBlobIfOrphaned(hash) {
if (hash === EMPTY_HASH ||
(await this.adapter.nymph.getEntity({ class: this.adapter.NymphResource, skipAc: true }, { type: '&', equal: ['hash', hash] }))) {
return;
}
try {
const blobDir = this.getBlobDirname(hash);
await fsp.unlink(path.resolve(blobDir, hash));
await fsp.rmdir(blobDir);
const blob2Dir = path.dirname(blobDir);
await fsp.rmdir(blob2Dir);
const blob3Dir = path.dirname(blob2Dir);
await fsp.rmdir(blob3Dir);
}
catch (e) {
if (e.code !== 'ENOTEMPTY' && e.code !== 'ENOENT') {
throw e;
}
}
}
async getStream(range) {
if (this.nymphResource.guid == null ||
this.nymphResource.hash === EMPTY_HASH ||
(await this.isCollection())) {
return Readable.from([]);
}
const filename = path.resolve(this.getBlobDirname(), this.nymphResource.hash);
const handle = await fsp.open(filename, 'r');
const stream = handle.createReadStream(range ? range : undefined);
stream.on('error', async () => {
await handle.close();
});
stream.on('close', async () => {
await handle.close();
});
return stream;
}
async setStream(input, _user, mediaType) {
if (await this.isCollection()) {
throw new MethodNotSupportedError('This resource is an existing collection.');
}
if (!(await this.nymphResource.$save())) {
throw new InternalServerError("Couldn't save resource entity.");
}
try {
await fsp.access(this.adapter.tempRoot, constants.F_OK);
}
catch (e) {
await fsp.mkdir(this.adapter.tempRoot);
}
const tempFilename = path.resolve(this.adapter.tempRoot, uuidv4());
const handle = await fsp.open(tempFilename, 'w');
const stream = handle.createWriteStream();
let size = 0;
const cryptoHash = crypto.createHash('sha384');
let hashResolve;
const hashPromise = new Promise((resolve) => (hashResolve = resolve));
const hashStream = new BackPressureTransform(async (chunk) => {
if (!cryptoHash.write(Buffer.from(chunk))) {
input.pause();
cryptoHash.once('drain', () => {
input.resume();
});
}
size += chunk.length;
return chunk;
}, async () => {
hashResolve(cryptoHash.digest('hex'));
cryptoHash.destroy();
});
input.pipe(hashStream.writable);
hashStream.readable.pipe(stream);
return new Promise((resolve, reject) => {
stream.on('close', async () => {
await handle.close();
hashStream.writable.destroy();
hashStream.readable.destroy();
const hash = await hashPromise;
const transaction = `nephele-hash-${hash}`;
const nymph = this.nymphResource.$nymph;
const tnymph = await this.nymphResource.$nymph.startTransaction(transaction);
this.nymphResource.$setNymph(tnymph);
const oldHash = this.nymphResource.hash;
try {
this.nymphResource.hash = hash;
this.nymphResource.size = size;
this.nymphResource.contentType =
(await fileTypeFromFile(tempFilename))?.mime ??
mediaType ??
'application/octet-stream';
if (!(await this.nymphResource.$save())) {
throw new InternalServerError("Couldn't save resource entity.");
}
const dirname = this.getBlobDirname();
const filename = path.resolve(dirname, hash);
try {
await fsp.access(dirname, constants.F_OK);
}
catch (e) {
await fsp.mkdir(dirname, { recursive: true });
}
await fsp.rename(tempFilename, filename);
await tnymph.commit(transaction);
this.nymphResource.$setNymph(nymph);
}
catch (e) {
await tnymph.rollback(transaction);
this.nymphResource.$setNymph(nymph);
try {
await fsp.unlink(tempFilename);
}
catch (e) {
}
reject(e);
}
if (oldHash !== hash) {
await this.deleteBlobIfOrphaned(oldHash);
}
resolve();
});
stream.on('error', async (err) => {
input.destroy(err);
cryptoHash.destroy();
await handle.close();
await fsp.unlink(tempFilename);
reject(err);
});
input.on('error', async (err) => {
stream.destroy(err);
cryptoHash.destroy();
await handle.close();
await fsp.unlink(tempFilename);
reject(err);
});
});
}
async create(_user) {
if (await this.exists()) {
throw new ResourceExistsError('A resource already exists here.');
}
this.nymphResource.hash = EMPTY_HASH;
this.nymphResource.size = 0;
if (!(await this.nymphResource.$save())) {
throw new InternalServerError("Couldn't save resource entity.");
}
}
async delete(_user) {
if (this.nymphResource.parent == null) {
throw new ForbiddenError("This resource can't be deleted.");
}
if (!(await this.exists())) {
throw new ResourceNotFoundError("This resource couldn't be found.");
}
if (await this.adapter.nymph.getEntity({ class: this.adapter.NymphResource, skipAc: true }, {
type: '&',
ref: ['parent', this.nymphResource],
})) {
throw new ForbiddenError('This resource is not empty.');
}
if (await this.nymphResource.$delete()) {
await this.deleteBlobIfOrphaned(this.nymphResource.hash);
}
else {
throw new InternalServerError("Couldn't delete resource entity.");
}
}
async copy(destination, baseUrl, user) {
if (this.nymphResource.parent == null) {
throw new ForbiddenError("This resource can't be copied.");
}
const destinationPathParts = this.adapter.urlToPathParts(destination, baseUrl);
if (destinationPathParts == null) {
throw new BadGatewayError('The destination URL is not under the namespace of this server.');
}
const destinationPath = `/${destinationPathParts.join('/')}`;
if (this.path === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(`${this.path}/`))) {
throw new ForbiddenError('The destination cannot be the same as or contained within the source.');
}
const destinationParent = await this.adapter.getNymphParent(destinationPathParts, this.rootResource);
if (!destinationParent) {
throw new ResourceTreeNotCompleteError('One or more intermediate collections must be created before this resource.');
}
let destinationNymphResource = await this.adapter.nymph.getEntity({
class: this.adapter.NymphResource,
skipAc: true,
}, {
type: '&',
equal: ['name', destinationPathParts[destinationPathParts.length - 1]],
ref: ['parent', destinationParent],
});
if (user instanceof NymphUser) {
const tilmeld = enforceTilmeld(this.adapter.nymph);
if (destinationNymphResource) {
if (!tilmeld.checkPermissions(destinationNymphResource, TilmeldAccessLevels.FULL_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to write to the destination.');
}
}
else {
if (!tilmeld.checkPermissions(destinationParent, TilmeldAccessLevels.READ_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to access the destination.');
}
if (!tilmeld.checkPermissions(destinationParent, TilmeldAccessLevels.WRITE_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to write to the destination.');
}
}
}
await this.nymphResource.$copy(destinationParent, destinationPathParts[destinationPathParts.length - 1], destinationNymphResource ?? undefined);
if (destinationNymphResource != null) {
await this.deleteBlobIfOrphaned(destinationNymphResource.hash);
}
}
async move(destination, baseUrl, user) {
if (this.nymphResource.parent == null) {
throw new ForbiddenError("This resource can't be copied.");
}
const destinationPathParts = this.adapter.urlToPathParts(destination, baseUrl);
if (destinationPathParts == null) {
throw new BadGatewayError('The destination URL is not under the namespace of this server.');
}
const destinationPath = `/${destinationPathParts.join('/')}`;
if (this.path === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(`${this.path}/`))) {
throw new ForbiddenError('The destination cannot be the same as or contained within the source.');
}
const destinationParent = await this.adapter.getNymphParent(destinationPathParts, this.rootResource);
if (!destinationParent) {
throw new ResourceTreeNotCompleteError('One or more intermediate collections must be created before this resource.');
}
let destinationNymphResource = await this.adapter.nymph.getEntity({
class: this.adapter.NymphResource,
skipAc: true,
}, {
type: '&',
equal: ['name', destinationPathParts[destinationPathParts.length - 1]],
ref: ['parent', destinationParent],
});
if (user instanceof NymphUser) {
const tilmeld = enforceTilmeld(this.adapter.nymph);
if (destinationNymphResource) {
if (!tilmeld.checkPermissions(destinationNymphResource, TilmeldAccessLevels.FULL_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to write to the destination.');
}
}
else {
if (!tilmeld.checkPermissions(destinationParent, TilmeldAccessLevels.READ_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to access the destination.');
}
if (!tilmeld.checkPermissions(destinationParent, TilmeldAccessLevels.WRITE_ACCESS, user)) {
throw new UnauthorizedError('You do not have permission to write to the destination.');
}
}
}
await this.nymphResource.$move(destinationParent, destinationPathParts[destinationPathParts.length - 1], destinationNymphResource ?? undefined);
if (destinationNymphResource != null) {
await this.deleteBlobIfOrphaned(destinationNymphResource.hash);
}
}
async getLength() {
return this.nymphResource.size;
}
async getEtag() {
return (this.nymphResource?.hash ?? EMPTY_HASH).slice(0, 32);
}
async getMediaType() {
if (await this.isCollection()) {
return null;
}
return this.nymphResource.contentType;
}
async getCanonicalName() {
return this.path.split('/').pop() ?? '';
}
async getCanonicalPath() {
if (await this.isCollection()) {
return `${this.path}/`;
}
return this.path;
}
async getCanonicalUrl() {
let pathname = this.path
.replace(/^\//, () => '')
.split('/')
.filter((part) => part !== '')
.map(encodeURIComponent)
.join('/');
if (await this.isCollection()) {
pathname = `${pathname}/`;
}
return new URL(pathname, this.baseUrl);
}
async isCollection() {
return !!this.nymphResource.collection;
}
async getInternalMembers(_user) {
if (!(await this.isCollection())) {
throw new MethodNotSupportedError('This is not a collection.');
}
const resources = [];
const nymphResources = await this.adapter.nymph.getEntities({ class: this.adapter.NymphResource }, {
type: '&',
ref: ['parent', this.nymphResource],
});
for (let nymphResource of nymphResources) {
resources.push(new Resource({
path: `${this.path}/${nymphResource.name}`,
baseUrl: this.baseUrl,
adapter: this.adapter,
nymphResource,
rootResource: this.rootResource,
}));
}
return resources;
}
async exists() {
return this.nymphResource.guid != null;
}
async getFreeSpace() {
return (await checkDiskSpace(this.adapter.root)).free;
}
async getTotalSpace() {
return (await checkDiskSpace(this.adapter.root)).size;
}
}
//# sourceMappingURL=Resource.js.map