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 {
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 = {

View File

@ -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<string> {
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<FileStat[]> {
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<FileStat> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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)
}
}

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,
"sourceMap": true,
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"noUncheckedIndexedAccess": true,