UNPKG

@nephele/adapter-file-system

Version:

File system adapter for the Nephele WebDAV server.

374 lines (330 loc) 10.8 kB
import path from 'node:path'; import fsp from 'node:fs/promises'; import { constants } from 'node:fs'; import type { Request } from 'express'; import type { Adapter as AdapterInterface, AuthResponse, Method, User, } from 'nephele'; import { BadGatewayError, MethodNotImplementedError, MethodNotSupportedError, ResourceNotFoundError, } from 'nephele'; import { userReadBit, userWriteBit, userExecuteBit, groupReadBit, groupWriteBit, groupExecuteBit, otherReadBit, otherWriteBit, otherExecuteBit, } from './FileSystemBits.js'; import Resource from './Resource.js'; export type AdapterConfig = { /** * The absolute path of the directory that acts as the root directory for the * service. */ root: string; /** * Whether to follow symlinks. */ followLinks?: boolean; /** * How to handle client requested properties. * * The client can request to add any arbitrary property it wants (the WebDAV * spec calls these "dead properties"), and this controls how that situation * is handled. * * - "meta-files": Save these properties in ".nephelemeta" files. * - "disallow": Refuse to save them and return an error to the client. * - "emulate": Don't actually save them, but return a success to the client. * * "meta-files" is the default, as the WebDAV spec states that WebDAV servers * "should" support setting these properties. However, if you don't want meta * files cluttering up your file system, you can make a choice: * * "disallow" will tell the client that any property it tries to set is * protected. A well written client will understand this and move on. * * "emulate" will tell the client that the property was successfully set, even * though it wasn't really. If a client is poorly written and can't handle an * error on property setting, this will allow Nephele to still work with that * client. * * This setting does not affect "live properties", like last modified date and * content length. */ properties?: 'meta-files' | 'disallow' | 'emulate'; /** * How to handle client requested locks. * * This works the same as "properties", except that "disallow" also causes * Nephele to report to the client that locks are not supported at all. * * Again, a poorly written WebDAV client may require "emulate" to work with * Nephele. */ locks?: 'meta-files' | 'disallow' | 'emulate'; /** * The maximum filesize in bytes to calculate etags by a CRC-32C checksum of * the file contents. * * Any files above this file size will use an etag of a CRC-32C checksum of * the size, created time, and modified time. This will significantly speed up * responses to requests for these files, but at the cost of reduced accuracy * of etags. A file that has the exact same content, but a different modified * time will not be pulled from cache by the client. * * - Set this value to `Infinity` if you wish to fully follow the WebDAV spec * to the letter. * - Set this value to `-1` if you want to absolutely minimize disk IO. * * By default, all etags will be based on file size, created date, and * modified date, since this only requires retrieving metadata from the file * system, which is very fast compared to actually retrieving file contents. * This could technically go against the WebDAV spec section 8.8, which reads, * 'For any given URL, an "ETag" value MUST NOT be reused for different * representations returned by GET.' A file the exact same size and exact same * created and modified dates with different contents, though extremely * unlikely, would return the same etag. */ contentEtagMaxBytes?: number; }; /** * Nephele file system adapter. */ export default class Adapter implements AdapterInterface { root: string; followLinks: boolean; properties: 'meta-files' | 'disallow' | 'emulate'; locks: 'meta-files' | 'disallow' | 'emulate'; stat: typeof fsp.stat; contentEtagMaxBytes: number; constructor({ root, followLinks = true, properties = 'meta-files', locks = 'meta-files', contentEtagMaxBytes = -1, }: AdapterConfig) { this.root = root.replace( new RegExp(`${path.sep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}?$`), () => path.sep, ); this.followLinks = followLinks; this.properties = properties; this.locks = locks; this.stat = this.followLinks ? fsp.stat : fsp.lstat; this.contentEtagMaxBytes = contentEtagMaxBytes; } urlToRelativePath(url: URL, baseUrl: URL) { if ( !decodeURIComponent(url.pathname) .replace(/\/?$/, () => '/') .startsWith(decodeURIComponent(baseUrl.pathname)) ) { return null; } return path.join( path.sep, ...decodeURIComponent(url.pathname) .substring(decodeURIComponent(baseUrl.pathname).length) .replace(/\/?$/, '') .split('/'), ); } urlToAbsolutePath(url: URL, baseUrl: URL) { const relativePath = this.urlToRelativePath(url, baseUrl); if (relativePath == null) { return null; } return path.join(this.root, relativePath); } async getUid(user: User): Promise<number> { return user.uid == null ? -1 : user.uid; } async getGid(user: User): Promise<number> { return user.gid == null ? -1 : user.gid; } async getGids(user: User): Promise<number[]> { return user.gids == null ? [] : user.gids; } async getComplianceClasses( _url: URL, _request: Request, _response: AuthResponse, ) { if (this.locks === 'disallow') { // Locks are disabled. return []; } // 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) { // What type of file access do we need? let access = 'u'; if (['GET', 'HEAD', 'COPY', 'OPTIONS', 'PROPFIND'].includes(method)) { // Read operations. access = 'r'; } if ( ['POST', 'PUT', 'DELETE', 'MOVE', 'MKCOL', 'PROPPATCH'].includes(method) ) { // Write operations. access = 'w'; } if (['SEARCH'].includes(method)) { // Execute operations. (Directory listing.) access = 'x'; } if (['LOCK', 'UNLOCK'].includes(method)) { // Require the user to have write permission to lock and unlock a // resource. access = 'w'; } if (access === 'u') { return false; } // We need the user and group IDs. const uid = await this.getUid(user); const gids = await this.getGids(user); // First make sure the server process and user has access to all // directories in the tree. const pathname = this.urlToRelativePath(url, baseUrl); const absolutePathname = this.urlToAbsolutePath(url, baseUrl); if (pathname == null || absolutePathname == null) { return false; } const parts = [ this.root, ...pathname.split(path.sep).filter((str) => str !== ''), ]; let exists = true; try { await fsp.access( absolutePathname, access === 'w' ? constants.W_OK : constants.R_OK, ); } catch (e: any) { exists = false; } if (uid >= 0) { for (let i = 1; i <= parts.length; i++) { const ipathname = path.join(path.sep, ...parts.slice(0, i)); // Check if the user can access it. try { const stats = await this.stat(ipathname); if (access === 'x' || i < parts.length) { if ( !( stats.mode & otherExecuteBit || (stats.uid === uid && stats.mode & userExecuteBit) || (gids.includes(stats.gid) && stats.mode & groupExecuteBit) ) ) { return false; } } if (i === parts.length && access === 'r' && exists) { if ( !( stats.mode & otherReadBit || (stats.uid === uid && stats.mode & userReadBit) || (gids.includes(stats.gid) && stats.mode & groupReadBit) ) ) { return false; } } if ( (i === parts.length && access === 'w') || (!exists && i === parts.length - 1) ) { if ( !( stats.mode & otherWriteBit || (stats.uid === uid && stats.mode & userWriteBit) || (gids.includes(stats.gid) && stats.mode & groupWriteBit) ) ) { return false; } } } catch (e: any) { if (exists || (i < parts.length && e.code !== 'ENOENT')) { return false; } } } } // If we get to here, it means either the file exists and user has // permission, or the file doesn't exist, and the user has access to all // directories above it. return true; } async getResource(url: URL, baseUrl: URL) { const path = this.urlToRelativePath(url, baseUrl); if (path == null) { throw new BadGatewayError( 'The given path is not managed by this server.', ); } const resource = new Resource({ adapter: this, baseUrl, path }); if (!(await resource.exists())) { throw new ResourceNotFoundError('Resource not found.'); } return resource; } async newResource(url: URL, baseUrl: URL) { const path = this.urlToRelativePath(url, baseUrl); if (path == null) { throw new BadGatewayError( 'The given path is not managed by this server.', ); } return new Resource({ adapter: this, baseUrl, path, collection: false }); } async newCollection(url: URL, baseUrl: URL) { const path = this.urlToRelativePath(url, baseUrl); if (path == null) { throw new BadGatewayError( 'The given path is not managed by this server.', ); } return new Resource({ adapter: this, baseUrl, path, collection: true }); } 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.'); } }