@nephele/adapter-nymph
Version:
Nymph.js based deduping file adapter for the Nephele WebDAV server.
592 lines (509 loc) • 15.4 kB
text/typescript
import path from 'node:path';
import fs from 'node:fs';
import { constants } from 'node:fs';
import type { Request } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
Nymph,
type Options,
type Selector,
TilmeldAccessLevels,
} from '@nymphjs/nymph';
import { SQLite3Driver } from '@nymphjs/driver-sqlite3';
import {
Tilmeld,
User as NymphUser,
enforceTilmeld,
AccessControlError,
} from '@nymphjs/tilmeld';
import type {
Adapter as AdapterInterface,
AuthResponse,
Method,
User,
} from 'nephele';
import {
BadGatewayError,
InternalServerError,
MethodNotImplementedError,
MethodNotSupportedError,
ResourceNotFoundError,
ResourceTreeNotCompleteError,
} from 'nephele';
import { Lock as NymphLock } from './entities/Lock.js';
import {
Resource as NymphResource,
ResourceData as NymphResourceData,
} from './entities/Resource.js';
import Resource from './Resource.js';
import { EMPTY_HASH } from './constants.js';
export type AdapterConfig = {
/**
* The absolute path of the directory that acts as the root directory for the
* service.
*/
root: string;
/**
* The instance of Nymph that will manage the data.
*
* If you do not provide one, a Nymph instance will be created that uses a
* SQLite3 database in the file root called "nephele.db".
*/
nymph?: Nymph;
/**
* A function to get the root resource of the namespace.
*
* The default implementation will look for a collection Resource without a
* parent. If one isn't found, one will be created with a UUIDv4 as a name.
*
* This does pose an issue if the user has read access to multiple root
* resources. The first one found will be used. If this is not acceptible, you
* must provide your own implementation.
*/
getRootResource?: () => Promise<NymphResource & NymphResourceData>;
};
/**
* Nephele file system adapter.
*/
export default class Adapter implements AdapterInterface {
root: string;
nymph: Nymph;
getRootResource: () => Promise<NymphResource & NymphResourceData>;
NymphLock: typeof NymphLock;
NymphResource: typeof NymphResource;
_rootResource: (NymphResource & NymphResourceData) | null = null;
get tempRoot() {
return path.resolve(this.root, 'temp');
}
get blobRoot() {
return path.resolve(this.root, 'blob');
}
constructor({ nymph, root, getRootResource }: AdapterConfig) {
this.root = root.replace(
new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`),
() => path.sep,
);
this.nymph =
nymph ||
new Nymph(
{},
new SQLite3Driver({
filename: path.resolve(this.root, 'nephele.db'),
wal: true,
}),
);
try {
this.NymphLock = this.nymph.getEntityClass(NymphLock);
} catch (e: any) {
this.NymphLock = this.nymph.addEntityClass(NymphLock);
}
try {
this.NymphResource = this.nymph.getEntityClass(NymphResource);
} catch (e: any) {
this.NymphResource = this.nymph.addEntityClass(NymphResource);
}
this.getRootResource =
getRootResource ??
(async () => {
if (this._rootResource == null) {
let rootResource = await this.nymph.getEntity(
{ class: this.NymphResource },
{
type: '&',
equal: [
['parent', null],
['collection', true],
],
},
);
if (rootResource == null) {
// Check for the old style root resource.
// (Root resource used to not have a defined parent, but now they
// have a null parent because that is faster to search for.)
rootResource = await this.nymph.getEntity(
{ class: this.NymphResource },
{
type: '&',
'!defined': 'parent',
equal: ['collection', true],
},
);
if (rootResource) {
rootResource.parent = null;
await rootResource.$save();
}
}
if (rootResource == null) {
rootResource = await this.NymphResource.factory();
rootResource.name = uuidv4();
rootResource.size = 0;
rootResource.contentType = 'inode/directory';
rootResource.collection = true;
rootResource.hash = EMPTY_HASH;
rootResource.parent = null;
if (!(await rootResource.$save())) {
throw new InternalServerError(
'Root resource could not be created.',
);
}
}
this._rootResource = rootResource;
}
return this._rootResource;
});
try {
fs.accessSync(this.root, constants.R_OK);
} catch (e: any) {
throw new Error(
"Can't read from given file system root. Does the directory exist?",
);
}
}
urlToPathParts(url: URL, baseUrl: URL) {
if (
!decodeURIComponent(url.pathname)
.replace(/\/?$/, () => '/')
.startsWith(decodeURIComponent(baseUrl.pathname))
) {
return null;
}
return decodeURIComponent(url.pathname)
.substring(decodeURIComponent(baseUrl.pathname).length)
.replace(/\/?$/, '')
.split('/')
.filter((str) => str !== '');
}
pathPartsToUrl(pathParts: string[], baseUrl: URL) {
return new URL(pathParts.map(encodeURIComponent).join('/'), baseUrl);
}
async getComplianceClasses(
_url: URL,
_request: Request,
_response: AuthResponse,
) {
// This adapter supports locks.
return ['2'];
}
async getAllowedMethods(
_url: URL,
_request: Request,
_response: AuthResponse,
) {
// This adapter doesn't support any WebDAV extensions that require
// additional methods.
return [];
}
async getOptionsResponseCacheControl(
_url: URL,
_request: Request,
_response: AuthResponse,
) {
// This adapter doesn't do anything special for individual URLs, so a max
// age of one week is fine.
return 'max-age=604800';
}
async isAuthorized(url: URL, method: string, baseUrl: URL, user: User) {
let tilmeld: Tilmeld;
try {
tilmeld = enforceTilmeld(this.nymph);
} catch (e: any) {
// If we don't have Tilmeld, then everything is authorized.
return true;
}
if (!(user instanceof NymphUser)) {
return false;
}
// What type of file access do we need?
let access = TilmeldAccessLevels.NO_ACCESS;
if (['GET', 'HEAD', 'COPY', 'OPTIONS', 'PROPFIND'].includes(method)) {
// Read operations.
access = TilmeldAccessLevels.READ_ACCESS;
}
if (['POST', 'PUT', 'MKCOL', 'PROPPATCH'].includes(method)) {
// Write operations.
access = TilmeldAccessLevels.WRITE_ACCESS;
}
if (['DELETE', 'MOVE'].includes(method)) {
// Move/delete operations.
access = TilmeldAccessLevels.FULL_ACCESS;
}
if (['SEARCH'].includes(method)) {
// Execute operations. (Directory listing.)
access = TilmeldAccessLevels.READ_ACCESS;
}
if (['LOCK', 'UNLOCK'].includes(method)) {
// Require the user to have write permission to lock and unlock a
// resource.
access = TilmeldAccessLevels.WRITE_ACCESS;
}
if (access === TilmeldAccessLevels.NO_ACCESS) {
return false;
}
// First make sure the server process and user has access to all
// directories in the tree.
const pathParts = this.urlToPathParts(url, baseUrl);
if (pathParts == null) {
// Not managed by this adapter.
return false;
}
if (pathParts.length === 0) {
// The user only has access to change their root, not delete or move it.
return access <= TilmeldAccessLevels.WRITE_ACCESS;
}
try {
let rootResource: NymphResource & NymphResourceData =
await this.getRootResource();
const parent = await this.getNymphParent(pathParts, rootResource);
if (!parent || parent.collection !== true) {
// The resource tree is not complete.
return false;
}
const curResource: (NymphResource & NymphResourceData) | null =
await this.nymph.getEntity(
{ class: this.NymphResource },
{
type: '&',
equal: ['name', pathParts[pathParts.length - 1]],
ref: ['parent', parent],
},
);
if (curResource == null) {
// Check the parent for at least write permission.
return tilmeld.checkPermissions(
parent,
Math.max(access, TilmeldAccessLevels.WRITE_ACCESS),
user,
);
} else {
// Check the resoure itself.
return tilmeld.checkPermissions(curResource, access, user);
}
} catch (e: any) {
if (e instanceof AccessControlError) {
return false;
}
throw e;
}
// We shouldn't ever get here, but just in case, return false.
return false;
}
async getNymphResource(
pathParts: string[],
rootResource: NymphResource & NymphResourceData,
) {
if (pathParts.length === 0) {
return rootResource;
}
let query = [
{ class: this.NymphResource },
{
type: '&',
guid: rootResource.guid,
},
] as [Options<typeof NymphResource>, ...Selector[]];
let depth = 0;
for (let i = 0; i < pathParts.length; i++) {
if (depth === 2) {
const resource = await this.nymph.getEntity(...query);
if (resource == null) {
return resource;
}
query = [
{ class: this.NymphResource },
{
type: '&',
guid: resource.guid,
},
] as [Options<typeof NymphResource>, ...Selector[]];
depth = 0;
}
const part = pathParts[i];
query = [
{ class: this.NymphResource },
{
type: '&',
equal: [
['name', part],
...(i < pathParts.length - 1 ? [['collection', true]] : []),
],
qref: ['parent', query],
},
] as [Options<typeof NymphResource>, ...Selector[]];
depth++;
}
return await this.nymph.getEntity(...query);
}
async getNymphParent(
pathParts: string[],
rootResource: NymphResource & NymphResourceData,
) {
if (pathParts.length <= 1) {
return rootResource;
}
const resource = await this.getNymphResource(
pathParts.slice(0, -1),
rootResource,
);
if (resource == null || resource.collection !== true) {
return false;
}
return resource;
}
async getResource(url: URL, baseUrl: URL) {
const pathParts = this.urlToPathParts(url, baseUrl);
if (pathParts == null) {
throw new BadGatewayError(
'The given path is not managed by this server.',
);
}
try {
const rootResource = await this.getRootResource();
if (pathParts.length === 0) {
return new Resource({
adapter: this,
baseUrl,
path: '/',
nymphResource: rootResource,
rootResource,
});
}
const nymphResource = await this.getNymphResource(
pathParts,
rootResource,
);
if (nymphResource == null) {
throw new ResourceNotFoundError('Resource not found.');
}
const resource = new Resource({
adapter: this,
baseUrl,
path: `/${pathParts.join('/')}`,
nymphResource,
rootResource,
});
return resource;
} catch (e: any) {
if (e instanceof AccessControlError) {
throw new ResourceNotFoundError('Resource not found.');
}
throw e;
}
}
async newResource(url: URL, baseUrl: URL) {
const pathParts = this.urlToPathParts(url, baseUrl);
if (pathParts == null) {
throw new BadGatewayError(
'The given path is not managed by this server.',
);
}
const rootResource = await this.getRootResource();
if (pathParts.length === 0) {
return new Resource({
adapter: this,
baseUrl,
path: '/',
nymphResource: rootResource,
rootResource,
});
}
const parent = await this.getNymphParent(pathParts, rootResource);
if (!parent) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
const nymphResource = await this.nymph.getEntity(
{ class: this.NymphResource },
{
type: '&',
equal: ['name', pathParts[pathParts.length - 1]],
ref: ['parent', parent],
},
);
if (nymphResource == null) {
const newResource = await this.NymphResource.factory();
newResource.name = pathParts[pathParts.length - 1];
newResource.hash = EMPTY_HASH;
newResource.size = 0;
newResource.contentType = 'application/octet-stream';
newResource.collection = false;
newResource.parent = parent;
return new Resource({
adapter: this,
baseUrl,
path: `/${pathParts.join('/')}`,
nymphResource: newResource,
rootResource,
});
}
return new Resource({
adapter: this,
baseUrl,
path: `/${pathParts.join('/')}`,
nymphResource,
rootResource,
});
}
async newCollection(url: URL, baseUrl: URL) {
const pathParts = this.urlToPathParts(url, baseUrl);
if (pathParts == null) {
throw new BadGatewayError(
'The given path is not managed by this server.',
);
}
const rootResource = await this.getRootResource();
if (pathParts.length === 0) {
return new Resource({
adapter: this,
baseUrl,
path: '/',
nymphResource: rootResource,
rootResource,
});
}
const parent = await this.getNymphParent(pathParts, rootResource);
if (!parent) {
throw new ResourceTreeNotCompleteError(
'One or more intermediate collections must be created before this resource.',
);
}
const nymphResource = await this.nymph.getEntity(
{ class: this.NymphResource },
{
type: '&',
equal: ['name', pathParts[pathParts.length - 1]],
ref: ['parent', parent],
},
);
if (nymphResource == null) {
const newResource = await this.NymphResource.factory();
newResource.name = pathParts[pathParts.length - 1];
newResource.hash = EMPTY_HASH;
newResource.size = 0;
newResource.contentType = 'inode/directory';
newResource.collection = true;
newResource.parent = parent;
return new Resource({
adapter: this,
baseUrl,
path: `/${pathParts.join('/')}`,
nymphResource: newResource,
rootResource,
});
}
return new Resource({
adapter: this,
baseUrl,
path: `/${pathParts.join('/')}`,
nymphResource,
rootResource,
});
}
getMethod(method: string): typeof Method {
// No additional methods to handle.
if (method === 'POST' || method === 'PATCH') {
throw new MethodNotSupportedError('Method not supported.');
}
throw new MethodNotImplementedError('Method not implemented.');
}
}