@nephele/adapter-file-system
Version:
File system adapter for the Nephele WebDAV server.
974 lines (836 loc) • 25.5 kB
text/typescript
import { Readable } from 'node:stream';
import fsp from 'node:fs/promises';
import type { Stats } from 'node:fs';
import { constants } from 'node:fs';
import path from 'node:path';
import mime from 'mime';
import checkDiskSpace from 'check-disk-space';
import crc32 from 'cyclic-32';
import type { Resource as ResourceInterface, User } from 'nephele';
import {
BadGatewayError,
ForbiddenError,
MethodNotSupportedError,
ResourceExistsError,
ResourceNotFoundError,
ResourceTreeNotCompleteError,
UnauthorizedError,
} from 'nephele';
import type Adapter from './Adapter.js';
import {
userReadBit,
userWriteBit,
userExecuteBit,
groupReadBit,
groupWriteBit,
groupExecuteBit,
otherReadBit,
otherWriteBit,
otherExecuteBit,
} from './FileSystemBits.js';
import Properties from './Properties.js';
import Lock from './Lock.js';
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;
// Don't use this directly. Call isCollection() instead.
private collection: boolean | undefined = undefined;
private etag: string | undefined = undefined;
private stats: Stats | undefined = undefined;
constructor({
adapter,
baseUrl,
path: myPath,
collection,
stats,
}: {
adapter: Adapter;
baseUrl: URL;
path: string;
collection?: boolean;
stats?: Stats;
}) {
this.adapter = adapter;
this.baseUrl = baseUrl;
this.path = myPath.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
'',
);
if (collection != null) {
this.collection = collection;
}
if (stats) {
this.stats = stats;
}
}
get absolutePath() {
return `${this.adapter.root}${path.sep}${this.path}`;
}
async getLocks() {
const meta = await this.readMetadataFile();
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.readMetadataFile();
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([]);
}
const handle = await fsp.open(this.absolutePath, '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: Readable, user: User) {
let exists = true;
try {
await fsp.access(path.dirname(this.absolutePath), constants.F_OK);
} catch (e: any) {
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.',
);
}
try {
await fsp.access(this.absolutePath, constants.W_OK);
} catch (e: any) {
exists = false;
}
if (!exists && user.uid != null) {
await fsp.writeFile(this.absolutePath, Buffer.from([]));
await fsp.chown(
this.absolutePath,
await this.adapter.getUid(user),
await this.adapter.getGid(user),
);
}
this.etag = undefined;
const handle = await fsp.open(this.absolutePath, 'w');
const stream = handle.createWriteStream();
// Reset stats, since they are going to change.
this.stats = undefined;
input.pipe(stream);
// Throttle throughput. Maybe add this as an option.
// input.on('data', (chunk) => {
// if (!stream.write(chunk)) {
// input.pause();
// stream.once('drain', () => input.resume());
// } else {
// input.pause();
// setTimeout(() => input.resume(), 50);
// }
// });
// input.on('end', async () => {
// await stream.close();
// });
return new Promise<void>((resolve, reject) => {
stream.on('close', async () => {
await handle.close();
resolve();
});
stream.on('error', async (err) => {
input.destroy(err);
await handle.close();
reject(err);
});
input.on('error', async (err) => {
stream.destroy(err);
await handle.close();
reject(err);
});
});
}
async create(user: User) {
if (await this.exists()) {
throw new ResourceExistsError('A resource already exists here.');
}
try {
await fsp.access(path.dirname(this.absolutePath), constants.F_OK);
} catch (e: any) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
if (this.collection) {
await fsp.mkdir(this.absolutePath);
} else {
await fsp.writeFile(this.absolutePath, Uint8Array.from([]));
}
if (user.uid != null) {
await fsp.chown(
this.absolutePath,
await this.adapter.getUid(user),
await this.adapter.getGid(user),
);
}
}
async delete(user: User) {
if (!(await this.exists())) {
throw new ResourceNotFoundError("This resource couldn't be found.");
}
try {
await fsp.access(this.absolutePath, constants.W_OK);
} catch (e: any) {
throw new ForbiddenError('This resource cannot be deleted.');
}
const metaFilePath = await this.getMetadataFilePath();
let metaFileExists = false;
try {
await fsp.access(metaFilePath, constants.F_OK);
metaFileExists = true;
} catch (e: any) {
metaFileExists = false;
}
if (metaFileExists) {
try {
await fsp.access(metaFilePath, constants.W_OK);
} catch (e: any) {
throw new ForbiddenError('This resource cannot be deleted.');
}
}
// We need the user and group IDs.
const uid = await this.adapter.getUid(user);
const gids = await this.adapter.getGids(user);
if (user.uid != null) {
// Check if the user can delete it.
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
if (
!(
this.stats.mode & otherWriteBit ||
(this.stats.uid === uid && this.stats.mode & userWriteBit) ||
(gids.includes(this.stats.gid) && this.stats.mode & groupWriteBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to delete this resource.',
);
}
}
if (metaFileExists) {
await fsp.unlink(metaFilePath);
}
if (await this.isCollection()) {
await this.deleteOrphanedMetadataFiles();
await fsp.rmdir(this.absolutePath);
} else {
await fsp.unlink(this.absolutePath);
}
// Reset stats.
this.stats = undefined;
}
async copy(destination: URL, baseUrl: URL, user: User) {
const destinationPath = this.adapter.urlToAbsolutePath(
destination,
baseUrl,
);
if (destinationPath == null) {
throw new BadGatewayError(
'The destination URL is not under the namespace of this server.',
);
}
if (
this.absolutePath === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(
this.absolutePath.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
),
))
) {
throw new ForbiddenError(
'The destination cannot be the same as or contained within the source.',
);
}
try {
await fsp.access(path.dirname(destinationPath), constants.F_OK);
} catch (e: any) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
// We need the user and group IDs.
const uid = await this.adapter.getUid(user);
const gids = await this.adapter.getGids(user);
if (user.uid != null) {
// Check if the user can put it in the destination.
const dstats = await fsp.stat(path.dirname(destinationPath));
if (
!(
dstats.mode & otherReadBit ||
(dstats.uid === uid && dstats.mode & userReadBit) ||
(gids.includes(dstats.gid) && dstats.mode & groupReadBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to access the destination.',
);
}
if (
!(
dstats.mode & otherWriteBit ||
(dstats.uid === uid && dstats.mode & userWriteBit) ||
(gids.includes(dstats.gid) && dstats.mode & groupWriteBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to write to the destination.',
);
}
}
let metaFilePath: string | undefined = undefined;
if (await this.isCollection()) {
try {
const stat = await fsp.stat(destinationPath);
if (stat.isDirectory()) {
const metaFilePath = `${destinationPath}${path.sep}.nephelemeta`;
const contents = await fsp.readdir(destinationPath);
if (
contents.length > 1 ||
(contents.length === 1 && contents[0] !== metaFilePath)
) {
throw new Error('Directory not empty.');
}
try {
await fsp.unlink(metaFilePath);
} catch (e: any) {
// Ignore errors deleting possible non-existent file.
}
await fsp.rmdir(destinationPath);
} else {
await fsp.unlink(destinationPath);
}
} catch (e: any) {
// Ignore errors stat-ing a possible non-existent directory and deleting
// a possibly non-empty directory.
}
try {
await fsp.mkdir(destinationPath);
} catch (e: any) {
// We don't care if the function failed just because it's a directory
// that already exists.
const stat = await fsp.stat(destinationPath);
if (!stat.isDirectory()) {
throw e;
}
}
try {
metaFilePath = `${destinationPath}${path.sep}.nephelemeta`;
try {
await fsp.unlink(metaFilePath);
} catch (e: any) {
// Ignore errors deleting a possibly non-existent file.
}
const meta = await this.readMetadataFile();
meta.locks = {};
await this.saveMetadataFile(meta, destinationPath, metaFilePath);
} catch (e: any) {
// Ignore errors while copying metadata files.
metaFilePath = undefined;
}
} else {
await fsp.copyFile(this.absolutePath, destinationPath);
try {
const dirname = path.dirname(destinationPath);
const basename = path.basename(destinationPath);
metaFilePath = `${dirname}${path.sep}${basename}.nephelemeta`;
try {
await fsp.unlink(metaFilePath);
} catch (e: any) {
// Ignore errors deleting a possibly non-existent file.
}
const meta = await this.readMetadataFile();
meta.locks = {};
await this.saveMetadataFile(meta, destinationPath, metaFilePath);
} catch (e: any) {
// Ignore errors while copying metadata files.
metaFilePath = undefined;
}
}
if (user.uid != null) {
const uid = await this.adapter.getUid(user);
const gid = await this.adapter.getGid(user);
// Set owner info.
await fsp.chown(destinationPath, uid, gid);
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
// Set permissions.
await fsp.chmod(destinationPath, this.stats.mode % 0o1000);
if (metaFilePath != null) {
try {
await fsp.chown(metaFilePath, uid, gid);
await fsp.chmod(metaFilePath, this.stats.mode % 0o1000);
} catch (e: any) {
// Ignore errors chown/chmod a possibly non-existent file.
}
}
}
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
// Copy mode.
try {
await fsp.chmod(destinationPath, this.stats.mode);
} catch (e: any) {
// Ignore errors copying mode.
}
// Copy dates.
try {
await fsp.utimes(destinationPath, this.stats.atime, this.stats.mtime);
} catch (e: any) {
// Ignore errors copying dates.
}
return;
}
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.urlToAbsolutePath(
destination,
baseUrl,
);
if (destinationPath == null) {
throw new BadGatewayError(
'The destination URL is not under the namespace of this server.',
);
}
if (
this.absolutePath === destinationPath ||
((await this.isCollection()) &&
destinationPath.startsWith(
this.absolutePath.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
),
))
) {
throw new ForbiddenError(
'The destination cannot be the same as or contained within the source.',
);
}
try {
await fsp.access(path.dirname(destinationPath), constants.F_OK);
} catch (e: any) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
// We need the user and group IDs.
const uid = await this.adapter.getUid(user);
const gids = await this.adapter.getGids(user);
if (user.uid != null) {
// Check if the user can move it.
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
if (
!(
this.stats.mode & otherWriteBit ||
(this.stats.uid === uid && this.stats.mode & userWriteBit) ||
(gids.includes(this.stats.gid) && this.stats.mode & groupWriteBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to move this resource.',
);
}
// Check if the user can put it in the destination.
const dstats = await fsp.stat(path.dirname(destinationPath));
if (
!(
dstats.mode & otherReadBit ||
(dstats.uid === uid && dstats.mode & userReadBit) ||
(gids.includes(dstats.gid) && dstats.mode & groupReadBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to access the destination.',
);
}
if (
!(
dstats.mode & otherWriteBit ||
(dstats.uid === uid && dstats.mode & userWriteBit) ||
(gids.includes(dstats.gid) && dstats.mode & groupWriteBit)
)
) {
throw new UnauthorizedError(
'You do not have permission to write to the destination.',
);
}
}
const metaFilePath = await this.getMetadataFilePath();
const meta = await this.readMetadataFile();
await fsp.rename(this.absolutePath, destinationPath);
try {
const dirname = path.dirname(destinationPath);
const basename = path.basename(destinationPath);
const destMetaFilePath = `${dirname}${path.sep}${basename}.nephelemeta`;
try {
await fsp.unlink(destMetaFilePath);
} catch (e: any) {
// Ignore errors deleting a possibly non-existent file.
}
meta.locks = {};
await this.saveMetadataFile(meta, destinationPath, destMetaFilePath);
try {
await fsp.unlink(metaFilePath);
} catch (e: any) {
// Ignore errors deleting a possibly non-existent file.
}
} catch (e: any) {
// Ignore errors while moving metadata files.
}
// Reset stats.
this.stats = undefined;
}
async getLength() {
if (await this.isCollection()) {
return 0;
}
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
return this.stats.size;
}
async getEtag() {
if (this.etag != null) {
return this.etag;
}
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
let etag: string;
if (
(await this.isCollection()) ||
this.stats.size > this.adapter.contentEtagMaxBytes
) {
etag = crc32
.c(
Buffer.from(
`size: ${this.stats.size}; birthtime: ${this.stats.birthtimeMs}; mtime: ${this.stats.mtimeMs}`,
'utf8',
),
)
.toString(16);
} else {
// Check if we can open the file.
try {
const handle = await fsp.open(this.absolutePath, 'r');
await handle.close();
} catch (e: any) {
throw new Error('Resource is not accessible.');
}
try {
etag = await new Promise(async (resolve, reject) => {
const stream = (await this.getStream()).pipe(
crc32.createHash({
seed: 0,
table: crc32.TABLE.CASTAGNOLI,
}),
);
stream.on('error', reject);
stream.on('data', (buffer: Buffer) => {
resolve(buffer.toString('hex'));
});
});
} catch (e: any) {
throw new Error('Etag could not be calculated.');
}
}
this.etag = etag;
return this.etag;
}
async getMediaType() {
if (await this.isCollection()) {
return null;
}
const mediaType = mime.getType(path.basename(this.absolutePath));
if (!mediaType) {
return 'application/octet-stream';
} else if (Array.isArray(mediaType)) {
return typeof mediaType[0] === 'string'
? mediaType[0]
: 'application/octet-stream';
} else if (typeof mediaType === 'string') {
return mediaType;
} else {
return 'application/octet-stream';
}
}
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 isCollection() {
if (this.collection != null) {
return this.collection;
}
try {
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
this.collection = this.stats.isDirectory();
return this.collection;
} catch (e: any) {
return false;
}
}
async getInternalMembers(user: User) {
if (!(await this.isCollection())) {
throw new MethodNotSupportedError('This is not a collection.');
}
// We need the user and group IDs.
const uid = await this.adapter.getUid(user);
const gids = await this.adapter.getGids(user);
if (user.uid != null) {
// Check if the user can list its contents.
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
if (
!(
this.stats.mode & otherExecuteBit ||
(this.stats.uid === uid && this.stats.mode & userExecuteBit) ||
(gids.includes(this.stats.gid) && this.stats.mode & groupExecuteBit)
)
) {
throw new UnauthorizedError(
"You do not have permission to list this collection's members.",
);
}
}
const listing = await fsp.readdir(this.absolutePath, {
withFileTypes: true,
});
const resources: Resource[] = [];
for (let dir of listing) {
if (dir.name.endsWith('.nephelemeta')) {
continue;
}
try {
// This adapter only supports directories, files, and symlinks.
if (!dir.isDirectory() && !dir.isFile() && !dir.isSymbolicLink()) {
continue;
}
resources.push(
new Resource({
path: `${this.path}${path.sep}${dir.name}`,
baseUrl: this.baseUrl,
adapter: this.adapter,
collection: dir.isDirectory(),
}),
);
} catch (e: any) {
continue;
}
}
return resources;
}
async exists() {
if (this.stats && this.stats.birthtime != null) {
return true;
}
try {
await fsp.access(this.absolutePath, constants.F_OK);
} catch (e: any) {
return false;
}
return true;
}
async getStats() {
if (!this.stats) {
this.stats = await fsp.stat(this.absolutePath);
}
return this.stats;
}
async setMode(mode: number) {
await fsp.chmod(this.absolutePath, mode);
await fsp.chmod(await this.getMetadataFilePath(), mode);
}
async getFreeSpace() {
const directory = (await this.isCollection())
? this.absolutePath
: path.dirname(this.absolutePath);
return (await checkDiskSpace(directory)).free;
}
async getTotalSpace() {
const directory = (await this.isCollection())
? this.absolutePath
: path.dirname(this.absolutePath);
return (await checkDiskSpace(directory)).size;
}
async getMetadataFilePath() {
if (await this.isCollection()) {
return `${this.absolutePath}${path.sep}.nephelemeta`;
} else {
const dirname = path.dirname(this.absolutePath);
const basename = path.basename(this.absolutePath);
return `${dirname}${path.sep}${basename}.nephelemeta`;
}
}
async readMetadataFile() {
const filepath = await this.getMetadataFilePath();
let meta: MetaStorage = {};
try {
meta = JSON.parse((await fsp.readFile(filepath)).toString());
} catch (e: any) {
if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') {
throw e;
}
}
return meta;
}
async saveMetadataFile(
meta: MetaStorage,
filePath?: string,
metaFilePath?: string,
) {
if (!metaFilePath) {
metaFilePath = await this.getMetadataFilePath();
}
let exists = true;
try {
await fsp.access(path.dirname(metaFilePath), constants.F_OK);
} catch (e: any) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
try {
await fsp.access(metaFilePath, constants.F_OK);
} catch (e: any) {
exists = false;
}
if (
(meta.props == null || Object.keys(meta.props).length === 0) &&
(meta.locks == null || Object.keys(meta.locks).length === 0)
) {
if (exists) {
// Delete metadata file, since it should now be empty.
await fsp.unlink(metaFilePath);
}
} else {
await fsp.writeFile(metaFilePath, JSON.stringify(meta, null, 2));
try {
const stat = filePath
? await fsp.stat(filePath)
: await this.getStats();
await fsp.chown(metaFilePath, stat.uid, stat.gid);
await fsp.chmod(metaFilePath, stat.mode % 0o1000);
} catch (e: any) {
// Ignore errors on setting ownership of meta file.
}
}
}
async deleteOrphanedMetadataFiles() {
if (!(await this.isCollection())) {
throw new MethodNotSupportedError('This is not a collection.');
}
const listing = await fsp.readdir(this.absolutePath);
const files: Set<string> = new Set();
const metaFiles: Set<string> = new Set();
for (let name of listing) {
if (name === '.nephelemeta') {
continue;
}
if (name.endsWith('.nephelemeta')) {
metaFiles.add(name);
} else {
files.add(name);
}
}
for (let name of files) {
metaFiles.delete(`${name}.nephelemeta`);
}
const orphans = Array.from(metaFiles);
for (let name of orphans) {
const orphanPath = `${this.absolutePath}${path.sep}${name}`;
await fsp.unlink(orphanPath);
}
}
}