This commit is contained in:
Ben Allfree 2024-09-16 19:06:21 +00:00
parent 5f532315c4
commit bdbcc479cf
3 changed files with 174 additions and 244 deletions

View File

@ -1,21 +1,9 @@
import { compact, map } from '@s-libs/micro-dash' import { compact, map } from '@s-libs/micro-dash'
import { import Bottleneck from 'bottleneck'
Mode, import { spawn } from 'child_process'
constants, import { Mode, constants, createReadStream, createWriteStream } from 'fs'
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
} from 'fs'
import { FileStat, FileSystem, FtpConnection } from 'ftp-srv' import { FileStat, FileSystem, FtpConnection } from 'ftp-srv'
import { isAbsolute, join, normalize, resolve, sep } from 'path' import { dirname, isAbsolute, join, normalize, resolve, sep } from 'path'
import {
FolderNamesMap,
INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES,
MAINTENANCE_ONLY_FOLDER_NAMES,
VirtualFolderNames,
virtualFolderGuard,
} from '.'
import { import {
InstanceFields, InstanceFields,
Logger, Logger,
@ -24,7 +12,9 @@ import {
newId, newId,
} from '../../../../../common' } from '../../../../../common'
import { DATA_ROOT } from '../../../../../core' import { DATA_ROOT } from '../../../../../core'
import { InstanceLogger, InstanceLoggerApi } from '../../../../../services'
import * as fsAsync from './fs-async' import * as fsAsync from './fs-async'
import { MAINTENANCE_ONLY_INSTANCE_ROOTS } from './guards'
export type PathError = { export type PathError = {
cause: { cause: {
@ -43,6 +33,73 @@ export type PathError = {
const UNIX_SEP_REGEX = /\//g const UNIX_SEP_REGEX = /\//g
const WIN_SEP_REGEX = /\\/g const WIN_SEP_REGEX = /\\/g
const checkBun = (virtualPath: string, cwd: string) => {
const [subdomain, maybeImportant, ...rest] = virtualPath
.split('/')
.filter((p) => !!p)
if (!subdomain) {
throw new Error(`Subdomain not found in path: ${virtualPath}`)
}
const isImportant =
rest.length === 0 &&
[`bun.lockb`, `package.json`].includes(maybeImportant || '')
if (isImportant) {
const logger = InstanceLogger(subdomain, `exec`, { ttl: 5000 })
logger.info(`${maybeImportant} changed, running bun install`)
launchBunInstall(subdomain, virtualPath, cwd).catch(console.error)
}
}
const runBun = (() => {
const bunLimiter = new Bottleneck({ maxConcurrent: 1 })
return (cwd: string, logger: InstanceLoggerApi) =>
bunLimiter.schedule(
() =>
new Promise<number | null>((resolve) => {
const proc = spawn(
'/root/.bun/bin/bun',
['install', `--no-save`, `--production`, `--ignore-scripts`],
{ cwd },
)
const tid = setTimeout(() => {
logger.error(`bun timeout after 10s`)
proc.kill()
}, 10000)
proc.stdout.on('data', (data) => {
logger.info(`${data}`)
})
proc.stderr.on('data', (data) => {
logger.error(`${data}`)
})
proc.on('close', (code) => {
logger.info(`bun exited with: ${code}`)
clearTimeout(tid)
resolve(code)
})
}),
)
})()
const launchBunInstall = (() => {
const runCache: { [key: string]: { runAgain: boolean } } = {}
return async (subdomain: string, virtualPath: string, cwd: string) => {
if (cwd in runCache) {
runCache[cwd]!.runAgain = true
return
}
runCache[cwd] = { runAgain: true }
while (runCache[cwd]!.runAgain) {
runCache[cwd]!.runAgain = false
const logger = InstanceLogger(subdomain, `exec`)
logger.info(`Launching 'bun install' in ${virtualPath}`)
await runBun(cwd, logger)
}
delete runCache[cwd]
}
})()
export class PhFs implements FileSystem { export class PhFs implements FileSystem {
private log: Logger private log: Logger
connection: FtpConnection connection: FtpConnection
@ -64,106 +121,77 @@ export class PhFs implements FileSystem {
return this._root return this._root
} }
async _resolvePath(path = '.') { async _resolvePath(virtualPath = '.') {
const { dbg } = this.log.create(`_resolvePath`) const { dbg } = this.log.create(`_resolvePath`)
// Unix separators normalize nicer on both unix and win platforms // Unix separators normalize nicer on both unix and win platforms
const resolvedPath = path.replace(WIN_SEP_REGEX, '/') const resolvedVirtualPath = virtualPath.replace(WIN_SEP_REGEX, '/')
// Join cwd with new path // Join cwd with new path
const joinedPath = isAbsolute(resolvedPath) const finalVirtualPath = isAbsolute(resolvedVirtualPath)
? normalize(resolvedPath) ? normalize(resolvedVirtualPath)
: join('/', this.cwd, resolvedPath) : join('/', this.cwd, resolvedVirtualPath)
console.log(`***joinedPath`, { joinedPath }) console.log(`***finalVirtualPath`, { finalVirtualPath })
// Create local filesystem path using the platform separator // Create local filesystem path using the platform separator
const [empty, subdomain, virtualRootFolderName, ...pathFromRootFolder] = const [empty, subdomain, ...restOfVirtualPath] = finalVirtualPath.split('/')
joinedPath.split('/')
dbg({ dbg({
joinedPath, finalVirtualPath,
subdomain, subdomain,
virtualRootFolderName, restOfVirtualPath,
restOfPath: pathFromRootFolder,
}) })
// Check if the root folder name is valid
if (virtualRootFolderName) {
virtualFolderGuard(virtualRootFolderName)
}
// Begin building the physical path // Begin building the physical path
const fsPathParts: string[] = [this._root] const fsPathParts: string[] = [this._root]
// Check if the instance is valid // Check if the instance is valid
const instance = await (async () => { const instance = await (async () => {
console.log(`***checking validity`, { subdomain }) console.log(`***checking validity`, { subdomain })
if (subdomain) { if (!subdomain) return
const instance = await this.client const instance = await this.client
.collection(`instances`) .collection(`instances`)
.getFirstListItem<InstanceFields>(`subdomain='${subdomain}'`) .getFirstListItem<InstanceFields>(`subdomain='${subdomain}'`)
if (!instance) { if (!instance) {
throw new Error(`${subdomain} not found.`) throw new Error(`${subdomain} not found.`)
} }
fsPathParts.push(instance.id) const instanceRootDir = restOfVirtualPath[0]
if (virtualRootFolderName) {
virtualFolderGuard(virtualRootFolderName)
const physicalFolderName = FolderNamesMap[virtualRootFolderName]
dbg({
fsPathParts,
virtualRootFolderName,
physicalFolderName,
instance,
})
if ( if (
MAINTENANCE_ONLY_FOLDER_NAMES.includes(virtualRootFolderName) && instanceRootDir &&
MAINTENANCE_ONLY_INSTANCE_ROOTS.includes(instanceRootDir) &&
!instance.maintenance !instance.maintenance
) { ) {
throw new Error( throw new Error(
`Instance must be in maintenance mode to access ${virtualRootFolderName}`, `Instance must be in maintenance mode to access ${instanceRootDir}`,
) )
} }
fsPathParts.push(physicalFolderName) fsPathParts.push(instance.id)
// Ensure folder exists dbg({
const rootFolderFsPath = resolve( fsPathParts,
join(...fsPathParts) instance,
.replace(UNIX_SEP_REGEX, sep) })
.replace(WIN_SEP_REGEX, sep),
)
dbg({ rootFolderFsPath })
if (!existsSync(rootFolderFsPath)) {
mkdirSync(rootFolderFsPath, { recursive: true })
}
}
if (resolvedPath.length > 0) fsPathParts.push(...pathFromRootFolder)
return instance return instance
}
})() })()
// Finalize the fs path // Finalize the fs path
const fsPath = resolve( const fsPath = resolve(
join(...fsPathParts) join(...fsPathParts, ...restOfVirtualPath)
.replace(UNIX_SEP_REGEX, sep) .replace(UNIX_SEP_REGEX, sep)
.replace(WIN_SEP_REGEX, sep), .replace(WIN_SEP_REGEX, sep),
) )
// Create FTP client path using unix separator // Create FTP client path using unix separator
const clientPath = joinedPath.replace(WIN_SEP_REGEX, '/') const clientPath = finalVirtualPath.replace(WIN_SEP_REGEX, '/')
dbg({ dbg({
clientPath, clientPath,
fsPath, fsPath,
subdomain, restOfVirtualPath,
virtualRootFolderName,
restOfPath: pathFromRootFolder,
instance, instance,
}) })
return { return {
clientPath, clientPath,
fsPath, fsPath,
subdomain,
rootFolderName: virtualRootFolderName as VirtualFolderNames | undefined,
pathFromRootFolder,
instance, instance,
} }
} }
@ -192,13 +220,12 @@ export class PhFs implements FileSystem {
.breadcrumb(`cwd:${this.cwd}`) .breadcrumb(`cwd:${this.cwd}`)
.breadcrumb(path) .breadcrumb(path)
const { fsPath, subdomain, rootFolderName, instance } = const { fsPath, instance } = await this._resolvePath(path)
await this._resolvePath(path)
/* /*
If a subdomain is not specified, we are in the user's root. List all subdomains. If a subdomain is not specified, we are in the user's root. List all subdomains.
*/ */
if (subdomain === '') { if (!instance) {
const instances = await this.client.collection(`instances`).getFullList() const instances = await this.client.collection(`instances`).getFullList()
return instances.map((i) => { return instances.map((i) => {
return { return {
@ -212,30 +239,8 @@ export class PhFs implements FileSystem {
} }
/* /*
If we have a subdomain, then it should have resolved to an instance owned by that user If we do have a root folder name, it will include teh instance ID at this point
*/ List it's contents
if (!instance) {
throw new Error(
`Something as gone wrong. An instance without a subdomain is not possible.`,
)
}
/*
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_VIRTUAL_FOLDER_NAMES.map((name) => ({
isDirectory: () => true,
mode: 0o755,
size: 0,
mtime: Date.parse(instance.updated),
name: name,
}))
}
/*
If we do have a root folder name, then we list its contents
*/ */
return fsAsync return fsAsync
.readdir(fsPath) .readdir(fsPath)
@ -260,8 +265,7 @@ export class PhFs implements FileSystem {
} }
async get(fileName: string): Promise<FileStat> { async get(fileName: string): Promise<FileStat> {
const { fsPath, subdomain, instance, rootFolderName, pathFromRootFolder } = const { fsPath, instance, clientPath } = await this._resolvePath(fileName)
await this._resolvePath(fileName)
const { dbg, error } = this.log const { dbg, error } = this.log
.create(`get`) .create(`get`)
@ -271,9 +275,9 @@ export class PhFs implements FileSystem {
dbg(`get`) dbg(`get`)
/* /*
If the subdomain is not specified, we are in the root If the instance subdomain is not specified, we are in the root
*/ */
if (!subdomain) { if (!instance) {
return { return {
isDirectory: () => true, isDirectory: () => true,
mode: 0o755, mode: 0o755,
@ -283,36 +287,17 @@ export class PhFs implements FileSystem {
} }
} }
/* const isInstanceRoot = clientPath.split('/').filter((p) => !!p).length === 0
We are in a subdomain, we must have an instance
*/
if (!instance) {
throw new Error(`Expected instance here`)
}
/* /*
If we don't have a root folder, we're at the instance subdomain level If we don't have a root folder, we're at the instance subdomain level
*/ */
if (!rootFolderName) { if (!isInstanceRoot) {
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: subdomain, name: instance.subdomain,
}
}
/*
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: rootFolderName,
} }
} }
@ -336,19 +321,11 @@ export class PhFs implements FileSystem {
.breadcrumb(fileName) .breadcrumb(fileName)
dbg(`write`) dbg(`write`)
const { fsPath, clientPath, rootFolderName, pathFromRootFolder, instance } = const { fsPath, clientPath, instance } = await this._resolvePath(fileName)
await this._resolvePath(fileName)
assert(instance, `Instance expected here`) assert(instance, `Instance expected here`)
const { append, start } = options || {} 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`)
}
const stream = createWriteStream(fsPath, { const stream = createWriteStream(fsPath, {
flags: !append ? 'w+' : 'a+', flags: !append ? 'w+' : 'a+',
start, start,
@ -360,8 +337,10 @@ export class PhFs implements FileSystem {
fsAsync.unlink(fsPath) fsAsync.unlink(fsPath)
}) })
stream.once('close', () => { stream.once('close', () => {
dbg(`write(${fileName}) closing`) const virtualPath = join(this.cwd, fileName)
dbg(`write(${virtualPath}) closing`)
stream.end() stream.end()
checkBun(virtualPath, dirname(fsPath))
}) })
return { return {
stream, stream,
@ -379,18 +358,10 @@ export class PhFs implements FileSystem {
.breadcrumb(fileName) .breadcrumb(fileName)
dbg(`read`) dbg(`read`)
const { fsPath, clientPath, pathFromRootFolder } = const { fsPath, clientPath } = await this._resolvePath(fileName)
await this._resolvePath(fileName)
const { start } = 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 return fsAsync
.stat(fsPath) .stat(fsPath)
.then((stat) => { .then((stat) => {
@ -412,17 +383,9 @@ export class PhFs implements FileSystem {
.breadcrumb(path) .breadcrumb(path)
dbg(`delete`) dbg(`delete`)
const { fsPath, clientPath, pathFromRootFolder, rootFolderName, instance } = const { fsPath, instance } = await this._resolvePath(path)
await this._resolvePath(path)
assert(instance, `Instance expected here`) assert(instance, `Instance expected here`)
/*
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) => { return fsAsync.stat(fsPath).then((stat) => {
if (stat.isDirectory()) { if (stat.isDirectory()) {
return fsAsync.rmdir(fsPath) return fsAsync.rmdir(fsPath)
@ -438,15 +401,7 @@ export class PhFs implements FileSystem {
.breadcrumb(path) .breadcrumb(path)
dbg(`mkdir`) dbg(`mkdir`)
const { fsPath, clientPath, pathFromRootFolder } = const { fsPath } = await this._resolvePath(path)
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) return fsAsync.mkdir(fsPath, { recursive: true }).then(() => fsPath)
} }
@ -459,29 +414,11 @@ export class PhFs implements FileSystem {
.breadcrumb(to) .breadcrumb(to)
dbg(`rename`) dbg(`rename`)
const { const { fsPath: fromPath, instance } = await this._resolvePath(from)
fsPath: fromPath,
pathFromRootFolder: fromPathFromRootFolder,
rootFolderName: fromRootFolderName,
instance,
} = await this._resolvePath(from)
const { const { fsPath: toPath } = await this._resolvePath(to)
fsPath: toPath,
pathFromRootFolder: toPathFromRootFolder,
rootFolderName: toRootFolderName,
} = await this._resolvePath(to)
assert(instance, `Instance expected here`) assert(instance, `Instance expected here`)
/*
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) return fsAsync.rename(fromPath, toPath)
} }
@ -494,15 +431,8 @@ export class PhFs implements FileSystem {
.breadcrumb(mode.toString()) .breadcrumb(mode.toString())
dbg(`chmod`) dbg(`chmod`)
const { fsPath, clientPath, pathFromRootFolder } = const { fsPath } = await this._resolvePath(path)
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 // noop
return fsAsync.chmod(fsPath, mode) return fsAsync.chmod(fsPath, mode)
} }

View File

@ -0,0 +1,47 @@
import { keys, values } from '@s-libs/micro-dash'
export enum VirtualFolderNames {
Cache = `.cache`,
Data = 'pb_data',
Public = 'pb_public',
Migrations = 'pb_migrations',
Hooks = 'pb_hooks',
}
export enum PhysicalFolderNames {
Cache = `.cache`,
Data = 'pb_data',
Public = 'pb_public',
Migrations = 'pb_migrations',
Hooks = 'pb_hooks',
}
export const MAINTENANCE_ONLY_INSTANCE_ROOTS: string[] = [
VirtualFolderNames.Data,
]
export const FolderNamesMap: {
[_ in VirtualFolderNames]: PhysicalFolderNames
} = {
[VirtualFolderNames.Cache]: PhysicalFolderNames.Cache,
[VirtualFolderNames.Data]: PhysicalFolderNames.Data,
[VirtualFolderNames.Public]: PhysicalFolderNames.Public,
[VirtualFolderNames.Migrations]: PhysicalFolderNames.Migrations,
[VirtualFolderNames.Hooks]: PhysicalFolderNames.Hooks,
} as const
export const INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES = keys(FolderNamesMap)
export const INSTANCE_ROOT_PHYSICAL_FOLDER_NAMES = values(FolderNamesMap)
export function isInstanceRootVirtualFolder(
name: string,
): name is VirtualFolderNames {
return INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES.includes(name as VirtualFolderNames)
}
export function virtualFolderGuard(
name: string,
): asserts name is VirtualFolderNames {
if (!isInstanceRootVirtualFolder(name)) {
// throw new Error(`Accessing ${name} is not allowed.`)
}
}

View File

@ -1,4 +1,3 @@
import { keys, values } from '@s-libs/micro-dash'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { FtpSrv } from 'ftp-srv' import { FtpSrv } from 'ftp-srv'
import { import {
@ -21,52 +20,6 @@ import { PhFs } from './PhFs'
export type FtpConfig = { mothershipUrl: string } export type FtpConfig = { mothershipUrl: string }
export enum VirtualFolderNames {
Cache = `.cache`,
Data = 'pb_data',
Public = 'pb_public',
Migrations = 'pb_migrations',
Hooks = 'pb_hooks',
}
export enum PhysicalFolderNames {
Cache = `.cache`,
Data = 'pb_data',
Public = 'pb_public',
Migrations = 'pb_migrations',
Hooks = 'pb_hooks',
}
export const MAINTENANCE_ONLY_FOLDER_NAMES: VirtualFolderNames[] = [
VirtualFolderNames.Data,
]
export const FolderNamesMap: {
[_ in VirtualFolderNames]: PhysicalFolderNames
} = {
[VirtualFolderNames.Cache]: PhysicalFolderNames.Cache,
[VirtualFolderNames.Data]: PhysicalFolderNames.Data,
[VirtualFolderNames.Public]: PhysicalFolderNames.Public,
[VirtualFolderNames.Migrations]: PhysicalFolderNames.Migrations,
[VirtualFolderNames.Hooks]: PhysicalFolderNames.Hooks,
} as const
export const INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES = keys(FolderNamesMap)
export const INSTANCE_ROOT_PHYSICAL_FOLDER_NAMES = values(FolderNamesMap)
export function isInstanceRootVirtualFolder(
name: string,
): name is VirtualFolderNames {
return INSTANCE_ROOT_VIRTUAL_FOLDER_NAMES.includes(name as VirtualFolderNames)
}
export function virtualFolderGuard(
name: string,
): asserts name is VirtualFolderNames {
if (!isInstanceRootVirtualFolder(name)) {
throw new Error(`Accessing ${name} is not allowed.`)
}
}
export const ftpService = mkSingleton((config: Partial<FtpConfig> = {}) => { export const ftpService = mkSingleton((config: Partial<FtpConfig> = {}) => {
const { mothershipUrl } = mergeConfig( const { mothershipUrl } = mergeConfig(
{ {