diff --git a/packages/daemon/src/services/FtpService/FtpService.ts b/packages/daemon/src/services/FtpService/FtpService.ts index af2e5183..b812a9b0 100644 --- a/packages/daemon/src/services/FtpService/FtpService.ts +++ b/packages/daemon/src/services/FtpService/FtpService.ts @@ -16,31 +16,20 @@ export type FtpConfig = SingletonBaseConfig & {} export enum FolderNames { PbData = 'pb_data', - PbStatic = 'pb_static', + // PbStatic = 'pb_static', PbMigrations = 'pb_migrations', PbWorker = 'worker', - PbBackup = 'backup', } -export const README_CONTENTS: { [_ in FolderNames]: string } = { - [FolderNames.PbBackup]: `This directory contains tgz backups of your data. PocketHost creates these automatically, or you can create them manually. For more information, see https://pockethost.io/docs/backups`, - [FolderNames.PbData]: `This directory contains your PocketBase data. For more information, see https://pockethost.io/docs/data`, - [FolderNames.PbStatic]: `This directory contains static files such as your web frontend. PocketHost will serve these when your instance URL receives a request. For more information, see https://pockethost.io/docs/static `, - [FolderNames.PbWorker]: `This directory contains your Deno worker. For more information, see https://pockethost.io/docs/workers`, - [FolderNames.PbMigrations]: `This directory contains your migrations. For more information, see https://pockethost.io/docs/migrations`, -} -export const README_NAME = 'readme.md' - -export const FOLDER_NAMES: FolderNames[] = [ - FolderNames.PbBackup, +export const INSTANCE_ROOT_FOLDER_NAMES: FolderNames[] = [ FolderNames.PbData, - FolderNames.PbStatic, + // FolderNames.PbStatic, FolderNames.PbWorker, FolderNames.PbMigrations, ] -export function isFolder(name: string): name is FolderNames { - return FOLDER_NAMES.includes(name as FolderNames) +export function isInstanceRootFolder(name: string): name is FolderNames { + return INSTANCE_ROOT_FOLDER_NAMES.includes(name as FolderNames) } const tls = { diff --git a/packages/daemon/src/services/FtpService/PhFs.ts b/packages/daemon/src/services/FtpService/PhFs.ts index a7b9a865..507f59c1 100644 --- a/packages/daemon/src/services/FtpService/PhFs.ts +++ b/packages/daemon/src/services/FtpService/PhFs.ts @@ -1,59 +1,180 @@ import { DAEMON_PB_DATA_DIR } from '$constants' import { Logger } from '@pockethost/common' -import { existsSync, mkdirSync } from 'fs' -import { FileStat, FileSystem, FtpConnection } from 'ftp-srv' -import { join } from 'path' -import { Readable } from 'stream' -import { PocketbaseClientApi } from '../clientService/PbClient' +import { compact, map } from '@s-libs/micro-dash' import { - FOLDER_NAMES, - isFolder, - README_CONTENTS, - README_NAME, -} from './FtpService' + constants, + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + Mode, +} from 'fs' +import { FileStat, FileSystem, FtpConnection } from 'ftp-srv' +import { customAlphabet } from 'nanoid' +import { isAbsolute, join, normalize, resolve, sep } from 'path' +import { PocketbaseClientApi } from '../clientService/PbClient' +import * as fsAsync from './fs-async' +import { INSTANCE_ROOT_FOLDER_NAMES, isInstanceRootFolder } from './FtpService' -export class PhFs extends FileSystem { +const nanoid = customAlphabet(`abcdefghijklmnop`) + +export type PathError = { + cause: { + errno: number + code: string + syscall: string + path: string + } + isOperational: boolean + errno: number + code: string + syscall: string + path: string +} + +const UNIX_SEP_REGEX = /\//g +const WIN_SEP_REGEX = /\\/g + +export class PhFs implements FileSystem { private log: Logger private client: PocketbaseClientApi + connection: FtpConnection + cwd: string + private _root: string constructor( connection: FtpConnection, client: PocketbaseClientApi, logger: Logger ) { - super(connection, { root: '/', cwd: '/' }) + const cwd = `/` + const root = DAEMON_PB_DATA_DIR + this.connection = connection this.client = client this.log = logger.create(`PhFs`) + this.cwd = normalize((cwd || '/').replace(WIN_SEP_REGEX, '/')) + this._root = resolve(root || process.cwd()) } - async chdir(path?: string | undefined): Promise { - this.log.dbg(`chdir`, path) - if (!path) { - throw new Error(`Expected path`) - } - const _path = path.startsWith('/') ? path : join(this.cwd, path) - const [empty, subdomain] = _path.split('/') - this.log.dbg({ _path, subdomain }) + get root() { + return this._root + } - if (subdomain) { - const instance = await this.client.getInstanceBySubdomain(subdomain) - if (!instance) { - throw new Error(`Subdomain not found`) + async _resolvePath(path = '.') { + const { dbg } = this.log.create(`_resolvePath`) + + // Unix separators normalize nicer on both unix and win platforms + const resolvedPath = path.replace(WIN_SEP_REGEX, '/') + + // Join cwd with new path + const joinedPath = isAbsolute(resolvedPath) + ? normalize(resolvedPath) + : join('/', this.cwd, resolvedPath) + + // Create local filesystem path using the platform separator + const [empty, subdomain, rootFolderName, ...pathFromRootFolder] = + joinedPath.split('/') + dbg({ + joinedPath, + subdomain, + rootFolderName, + restOfPath: pathFromRootFolder, + }) + + // Check if the root folder name is valid + if (rootFolderName) { + if (!isInstanceRootFolder(rootFolderName)) { + throw new Error(`Accessing ${rootFolderName} is not allowed.`) } } - this.cwd = _path - return path + + // Begin building the physical path + const fsPathParts: string[] = [this._root] + + // Check if the instance is valid + const instance = await (async () => { + if (subdomain) { + const [instance] = await this.client.getInstanceBySubdomain(subdomain) + if (!instance) { + throw new Error(`${subdomain} not found.`) + } + fsPathParts.push(instance.id) + if (rootFolderName) { + fsPathParts.push(rootFolderName) + // Ensure folder exists + const rootFolderFsPath = resolve( + join(...fsPathParts) + .replace(UNIX_SEP_REGEX, sep) + .replace(WIN_SEP_REGEX, sep) + ) + if (!existsSync(rootFolderFsPath)) { + mkdirSync(rootFolderFsPath) + } + } + if (resolvedPath.length > 0) fsPathParts.push(...pathFromRootFolder) + return instance + } + })() + + // Finalize the fs path + const fsPath = resolve( + join(...fsPathParts) + .replace(UNIX_SEP_REGEX, sep) + .replace(WIN_SEP_REGEX, sep) + ) + + // Create FTP client path using unix separator + const clientPath = joinedPath.replace(WIN_SEP_REGEX, '/') + + dbg({ + clientPath, + fsPath, + subdomain, + rootFolderName, + restOfPath: pathFromRootFolder, + instance, + }) + + return { + clientPath, + fsPath, + subdomain, + rootFolderName, + pathFromRootFolder, + instance, + } } - async list(path?: string | undefined): Promise { - this.log.dbg(`list ${path}`, this.cwd) - if (!path) { - throw new Error(`Expected path`) - } - const _path = path.startsWith('/') ? path : join(this.cwd, path) - const [empty, subdomain, folderName, ...restOfPath] = _path.split('/') - this.log.dbg({ _path, subdomain, folderName, restOfPath }) + currentDirectory() { + return this.cwd + } + async chdir(path = '.') { + const { fsPath, clientPath } = await this._resolvePath(path) + + return fsAsync + .stat(fsPath) + .then((stat) => { + if (!stat.isDirectory()) throw new Error('Not a valid directory') + }) + .then(() => { + this.cwd = clientPath + return this.currentDirectory() + }) + } + + async list(path = '.') { + const { dbg, error } = this.log + .create(`list`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(path) + + const { fsPath, subdomain, rootFolderName, instance } = + await this._resolvePath(path) + + /* + If a subdomain is not specified, we are in the user's root. List all subdomains. + */ if (subdomain === '') { const instances = await this.client.getInstances() return instances.map((i) => { @@ -66,15 +187,22 @@ export class PhFs extends FileSystem { } }) } - if (!subdomain) { - throw new Error(`Subdomain expected in ${_path}`) - } - const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) + + /* + If we have a subdomain, then it should have resolved to an instance owned by that user + */ if (!instance) { - throw new Error(`Expected instance here`) + throw new Error( + `Something as gone wrong. An instance without a subdomain is not possible.` + ) } - if (!folderName) { - return FOLDER_NAMES.map((name) => ({ + + /* + If there is no root folder name, then we are in the instance root. In this case, list + our allowed folder names. + */ + if (!rootFolderName) { + return INSTANCE_ROOT_FOLDER_NAMES.map((name) => ({ isDirectory: () => true, mode: 0o755, size: 0, @@ -82,33 +210,46 @@ export class PhFs extends FileSystem { name: name, })) } - if (!isFolder(folderName)) { - throw new Error(`Top level folder name ${folderName} not allowed.`) - } - const dir = join(DAEMON_PB_DATA_DIR, instance.id, folderName, ...restOfPath) - this.log.dbg({ dir, exists: existsSync(dir) }) - return [ - ...(restOfPath.length === 0 - ? [ - { - isDirectory: () => false, - mode: 0o444, - size: README_CONTENTS[folderName].length, - mtime: Date.parse(instance.updated), - name: README_NAME, - }, - ] - : []), - ...(existsSync(dir) ? await super.list(dir) : []), - ] + + /* + If we do have a root folder name, then we list its contents + */ + return fsAsync + .readdir(fsPath) + .then((fileNames) => { + return Promise.all( + map(fileNames, (fileName) => { + const filePath = join(fsPath, fileName) + return fsAsync + .access(filePath, constants.F_OK) + .then(() => { + return fsAsync.stat(filePath).then((stat) => { + const _stat = stat as unknown as FileStat + _stat.name = fileName + return _stat + }) + }) + .catch(() => null) + }) + ) + }) + .then(compact) } async get(fileName: string): Promise { - const path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) - const [empty, subdomain, folderName, ...fNames] = path.split('/') - const fName = fNames.join('/') - this.log.dbg(`get`, { _path: path, subdomain, folderName, fName, fileName }) + const { fsPath, subdomain, instance, rootFolderName, pathFromRootFolder } = + await this._resolvePath(fileName) + const { dbg, error } = this.log + .create(`get`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(fileName) + .breadcrumb(fsPath) + dbg(`get`) + + /* + If the subdomain is not specified, we are in the root + */ if (!subdomain) { return { isDirectory: () => true, @@ -118,11 +259,18 @@ export class PhFs extends FileSystem { name: '/', } } - const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) + + /* + We are in a subdomain, we must have an instance + */ if (!instance) { throw new Error(`Expected instance here`) } - if (!folderName) { + + /* + If we don't have a root folder, we're at the instance subdomain level + */ + if (!rootFolderName) { return { isDirectory: () => true, mode: 0o755, @@ -131,116 +279,196 @@ export class PhFs extends FileSystem { name: subdomain, } } - if (!isFolder(folderName)) { - throw new Error(`Invalid folder name`) - } - if (!fName) { + + /* + If we don't have a path beneath the root folder, we're at a root folder level + */ + if (!pathFromRootFolder) { return { isDirectory: () => true, mode: 0o755, size: 0, mtime: Date.parse(instance.updated), - name: folderName, + name: rootFolderName, } } - const physicalPath = join( - DAEMON_PB_DATA_DIR, - instance.id, - folderName, - fName - ) - this.log.dbg({ physicalPath, exists: existsSync(physicalPath) }) - return super.get(physicalPath) + /* + Otherwise, this might be an actual file + */ + return fsAsync.stat(fsPath).then((stat) => { + const _stat = stat as unknown as FileStat + _stat.name = fileName + return _stat + }) } async write( fileName: string, options?: { append?: boolean | undefined; start?: any } | undefined ) { - const _path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) - const [empty, subdomain, folderName, ...fNames] = _path.split('/') - const fName = fNames.join('/') - this.log.dbg(`read`, { subdomain, folderName, fName }) + const { dbg, error } = this.log + .create(`write`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(fileName) + dbg(`write`) - if (!subdomain) { - throw new Error(`Subdomain not found`) - } - if (!folderName) { - throw new Error(`Folder name expected here`) - } - const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) - if (!instance) { - throw new Error(`Expected instance here`) - } - if (!isFolder(folderName)) { - throw new Error(`Invalid folder name ${folderName}`) + const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath( + fileName + ) + const { append, start } = options || {} + + /* + If we are not in a root folder, disallow writing + */ + if (pathFromRootFolder.length === 0) { + throw new Error(`write not allowed at this level`) } - if (fName === README_NAME) { - throw new Error(`Can't overwrite ${fName}`) + const stream = createWriteStream(fsPath, { + flags: !append ? 'w+' : 'a+', + start, + }) + stream.once('error', () => fsAsync.unlink(fsPath)) + stream.once('close', () => stream.end()) + return { + stream, + clientPath, } - const dir = join(DAEMON_PB_DATA_DIR, instance.id, folderName) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - return super.write(join(dir, fName), options) } async read( fileName: string, options: { start?: any } | undefined ): Promise { - const _path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) - const [empty, subdomain, folderName, ...fNames] = _path.split('/') - const fName = fNames.join('/') - this.log.dbg(`read`, { subdomain, folderName, fName }) + const { dbg, error } = this.log + .create(`read`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(fileName) + dbg(`read`) - if (!subdomain) { - throw new Error(`Subdomain not found`) - } - if (!folderName) { - throw new Error(`Folder name expected here`) - } - const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) - if (!instance) { - throw new Error(`Expected instance here`) - } - if (!isFolder(folderName)) { - throw new Error(`Invalid folder name ${folderName}`) - } - - if (fName === README_NAME) { - return Readable.from([README_CONTENTS[folderName]]) - } - const realPath = join( - `/`, - DAEMON_PB_DATA_DIR, - instance.id, - folderName, - fName + const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath( + fileName ) - this.log.dbg({ realPath }) - return super.read(realPath, options) + + const { start } = options || {} + + /* + If we are not in a root folder, disallow reading + */ + if (pathFromRootFolder.length === 0) { + throw new Error(`read not allowed at this level`) + } + + return fsAsync + .stat(fsPath) + .then((stat) => { + if (stat.isDirectory()) throw new Error('Cannot read a directory') + }) + .then(() => { + const stream = createReadStream(fsPath, { flags: 'r', start }) + return { + stream, + clientPath, + } + }) } - async delete(path: string): Promise { - throw new Error(`delete ${path}`) + async delete(path: string) { + const { dbg, error } = this.log + .create(`delete`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(path) + dbg(`delete`) + + const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath( + path + ) + + /* + Disallow deleting if not inside root folder + */ + if (pathFromRootFolder.length === 0) { + throw new Error(`delete not allowed at this level`) + } + + return fsAsync.stat(fsPath).then((stat) => { + if (stat.isDirectory()) return fsAsync.rmdir(fsPath) + else return fsAsync.unlink(fsPath) + }) } - async mkdir(path: string): Promise { - throw new Error(`mkdir ${path}`) + async mkdir(path: string) { + const { dbg, error } = this.log + .create(`mkdir`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(path) + dbg(`mkdir`) + + const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath( + path + ) + + /* + Disallow making directories if not inside root folder + */ + if (pathFromRootFolder.length === 0) { + throw new Error(`mkdir not allowed at this level`) + } + + return fsAsync.mkdir(fsPath, { recursive: true }).then(() => fsPath) } - async rename(from: string, to: string): Promise { - throw new Error(`rename ${from} ${to}`) + async rename(from: string, to: string) { + const { dbg, error } = this.log + .create(`rename`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(from) + .breadcrumb(to) + dbg(`rename`) + + const { fsPath: fromPath, pathFromRootFolder: fromPathFromRootFolder } = + await this._resolvePath(from) + + const { fsPath: toPath, pathFromRootFolder: toPathFromRootFolder } = + await this._resolvePath(from) + + /* + Disallow making directories if not inside root folder + */ + if (fromPathFromRootFolder.length === 0) { + throw new Error(`rename not allowed at this level`) + } + if (toPathFromRootFolder.length === 0) { + throw new Error(`rename not allowed at this level`) + } + + return fsAsync.rename(fromPath, toPath) } - async chmod(path: string, mode: string): Promise { - throw new Error(`chmod ${path} ${mode}`) + async chmod(path: string, mode: Mode) { + const { dbg, error } = this.log + .create(`chmod`) + .breadcrumb(`cwd:${this.cwd}`) + .breadcrumb(path) + .breadcrumb(mode.toString()) + dbg(`chmod`) + + const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath( + path + ) + + /* + Disallow making directories if not inside root folder + */ + if (pathFromRootFolder.length === 0) { + throw new Error(`chmod not allowed at this level`) + } + return // noop + return fsAsync.chmod(fsPath, mode) } - getUniqueName(fileName: string): string { - throw new Error(`getUniqueName ${fileName}`) + getUniqueName() { + return nanoid(30) } } diff --git a/packages/daemon/src/services/FtpService/fs-async.ts b/packages/daemon/src/services/FtpService/fs-async.ts new file mode 100644 index 00000000..5da1537a --- /dev/null +++ b/packages/daemon/src/services/FtpService/fs-async.ts @@ -0,0 +1,13 @@ +import fs from 'fs' +import { promisify } from 'util' + +const stat = promisify(fs.stat) +const readdir = promisify(fs.readdir) +const access = promisify(fs.access) +const unlink = promisify(fs.unlink) +const rmdir = promisify(fs.rmdir) +const mkdir = promisify(fs.mkdir) +const rename = promisify(fs.rename) +const chmod = promisify(fs.chmod) + +export { stat, readdir, access, unlink, rmdir, mkdir, rename, chmod } diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json index 23fcb9c0..6179200c 100644 --- a/packages/daemon/tsconfig.json +++ b/packages/daemon/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, + "target": "ESNext", "module": "ESNext", "moduleResolution": "node", "noUncheckedIndexedAccess": true,