enh: FTP support

This commit is contained in:
Ben Allfree 2023-06-22 21:36:59 -07:00
parent a1116d3faf
commit 29f4e5c477
4 changed files with 388 additions and 157 deletions

View File

@ -16,31 +16,20 @@ export type FtpConfig = SingletonBaseConfig & {}
export enum FolderNames { export enum FolderNames {
PbData = 'pb_data', PbData = 'pb_data',
PbStatic = 'pb_static', // PbStatic = 'pb_static',
PbMigrations = 'pb_migrations', PbMigrations = 'pb_migrations',
PbWorker = 'worker', PbWorker = 'worker',
PbBackup = 'backup',
} }
export const README_CONTENTS: { [_ in FolderNames]: string } = { export const INSTANCE_ROOT_FOLDER_NAMES: FolderNames[] = [
[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,
FolderNames.PbData, FolderNames.PbData,
FolderNames.PbStatic, // FolderNames.PbStatic,
FolderNames.PbWorker, FolderNames.PbWorker,
FolderNames.PbMigrations, FolderNames.PbMigrations,
] ]
export function isFolder(name: string): name is FolderNames { export function isInstanceRootFolder(name: string): name is FolderNames {
return FOLDER_NAMES.includes(name as FolderNames) return INSTANCE_ROOT_FOLDER_NAMES.includes(name as FolderNames)
} }
const tls = { const tls = {

View File

@ -1,59 +1,180 @@
import { DAEMON_PB_DATA_DIR } from '$constants' import { DAEMON_PB_DATA_DIR } from '$constants'
import { Logger } from '@pockethost/common' import { Logger } from '@pockethost/common'
import { existsSync, mkdirSync } from 'fs' import { compact, map } from '@s-libs/micro-dash'
import { FileStat, FileSystem, FtpConnection } from 'ftp-srv'
import { join } from 'path'
import { Readable } from 'stream'
import { PocketbaseClientApi } from '../clientService/PbClient'
import { import {
FOLDER_NAMES, constants,
isFolder, createReadStream,
README_CONTENTS, createWriteStream,
README_NAME, existsSync,
} from './FtpService' 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 log: Logger
private client: PocketbaseClientApi private client: PocketbaseClientApi
connection: FtpConnection
cwd: string
private _root: string
constructor( constructor(
connection: FtpConnection, connection: FtpConnection,
client: PocketbaseClientApi, client: PocketbaseClientApi,
logger: Logger logger: Logger
) { ) {
super(connection, { root: '/', cwd: '/' }) const cwd = `/`
const root = DAEMON_PB_DATA_DIR
this.connection = connection
this.client = client this.client = client
this.log = logger.create(`PhFs`) 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<string> { get root() {
this.log.dbg(`chdir`, path) return this._root
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 })
if (subdomain) { async _resolvePath(path = '.') {
const instance = await this.client.getInstanceBySubdomain(subdomain) const { dbg } = this.log.create(`_resolvePath`)
if (!instance) {
throw new Error(`Subdomain not found`) // 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<FileStat[]> { currentDirectory() {
this.log.dbg(`list ${path}`, this.cwd) return 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 })
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 === '') { if (subdomain === '') {
const instances = await this.client.getInstances() const instances = await this.client.getInstances()
return instances.map((i) => { return instances.map((i) => {
@ -66,15 +187,22 @@ export class PhFs extends FileSystem {
} }
}) })
} }
if (!subdomain) {
throw new Error(`Subdomain expected in ${_path}`) /*
} If we have a subdomain, then it should have resolved to an instance owned by that user
const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) */
if (!instance) { 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, isDirectory: () => true,
mode: 0o755, mode: 0o755,
size: 0, size: 0,
@ -82,33 +210,46 @@ export class PhFs extends FileSystem {
name: name, name: name,
})) }))
} }
if (!isFolder(folderName)) {
throw new Error(`Top level folder name ${folderName} not allowed.`) /*
} If we do have a root folder name, then we list its contents
const dir = join(DAEMON_PB_DATA_DIR, instance.id, folderName, ...restOfPath) */
this.log.dbg({ dir, exists: existsSync(dir) }) return fsAsync
return [ .readdir(fsPath)
...(restOfPath.length === 0 .then((fileNames) => {
? [ return Promise.all(
{ map(fileNames, (fileName) => {
isDirectory: () => false, const filePath = join(fsPath, fileName)
mode: 0o444, return fsAsync
size: README_CONTENTS[folderName].length, .access(filePath, constants.F_OK)
mtime: Date.parse(instance.updated), .then(() => {
name: README_NAME, return fsAsync.stat(filePath).then((stat) => {
}, const _stat = stat as unknown as FileStat
] _stat.name = fileName
: []), return _stat
...(existsSync(dir) ? await super.list(dir) : []), })
] })
.catch(() => null)
})
)
})
.then(compact)
} }
async get(fileName: string): Promise<FileStat> { async get(fileName: string): Promise<FileStat> {
const path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) const { fsPath, subdomain, instance, rootFolderName, pathFromRootFolder } =
const [empty, subdomain, folderName, ...fNames] = path.split('/') await this._resolvePath(fileName)
const fName = fNames.join('/')
this.log.dbg(`get`, { _path: path, subdomain, folderName, fName, 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) { if (!subdomain) {
return { return {
isDirectory: () => true, isDirectory: () => true,
@ -118,11 +259,18 @@ export class PhFs extends FileSystem {
name: '/', name: '/',
} }
} }
const [instance, user] = await this.client.getInstanceBySubdomain(subdomain)
/*
We are in a subdomain, we must have an instance
*/
if (!instance) { if (!instance) {
throw new Error(`Expected instance here`) 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 { return {
isDirectory: () => true, isDirectory: () => true,
mode: 0o755, mode: 0o755,
@ -131,116 +279,196 @@ export class PhFs extends FileSystem {
name: subdomain, name: subdomain,
} }
} }
if (!isFolder(folderName)) {
throw new Error(`Invalid folder name`) /*
} If we don't have a path beneath the root folder, we're at a root folder level
if (!fName) { */
if (!pathFromRootFolder) {
return { return {
isDirectory: () => true, isDirectory: () => true,
mode: 0o755, mode: 0o755,
size: 0, size: 0,
mtime: Date.parse(instance.updated), 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( async write(
fileName: string, fileName: string,
options?: { append?: boolean | undefined; start?: any } | undefined options?: { append?: boolean | undefined; start?: any } | undefined
) { ) {
const _path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) const { dbg, error } = this.log
const [empty, subdomain, folderName, ...fNames] = _path.split('/') .create(`write`)
const fName = fNames.join('/') .breadcrumb(`cwd:${this.cwd}`)
this.log.dbg(`read`, { subdomain, folderName, fName }) .breadcrumb(fileName)
dbg(`write`)
if (!subdomain) { const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath(
throw new Error(`Subdomain not found`) fileName
} )
if (!folderName) { const { append, start } = options || {}
throw new Error(`Folder name expected here`)
} /*
const [instance, user] = await this.client.getInstanceBySubdomain(subdomain) If we are not in a root folder, disallow writing
if (!instance) { */
throw new Error(`Expected instance here`) if (pathFromRootFolder.length === 0) {
} throw new Error(`write not allowed at this level`)
if (!isFolder(folderName)) {
throw new Error(`Invalid folder name ${folderName}`)
} }
if (fName === README_NAME) { const stream = createWriteStream(fsPath, {
throw new Error(`Can't overwrite ${fName}`) 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( async read(
fileName: string, fileName: string,
options: { start?: any } | undefined options: { start?: any } | undefined
): Promise<any> { ): Promise<any> {
const _path = fileName.startsWith('/') ? fileName : join(this.cwd, fileName) const { dbg, error } = this.log
const [empty, subdomain, folderName, ...fNames] = _path.split('/') .create(`read`)
const fName = fNames.join('/') .breadcrumb(`cwd:${this.cwd}`)
this.log.dbg(`read`, { subdomain, folderName, fName }) .breadcrumb(fileName)
dbg(`read`)
if (!subdomain) { const { fsPath, clientPath, pathFromRootFolder } = await this._resolvePath(
throw new Error(`Subdomain not found`) fileName
}
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
) )
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<any> { async delete(path: string) {
throw new Error(`delete ${path}`) 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<any> { async mkdir(path: string) {
throw new Error(`mkdir ${path}`) 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<any> { async rename(from: string, to: string) {
throw new Error(`rename ${from} ${to}`) 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<any> { async chmod(path: string, mode: Mode) {
throw new Error(`chmod ${path} ${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 { getUniqueName() {
throw new Error(`getUniqueName ${fileName}`) return nanoid(30)
} }
} }

View File

@ -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 }

View File

@ -9,6 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,