Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,792 changes: 833 additions & 959 deletions extensions/entity-files/packages/simple-file-server/package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"sharp": "^0.34.1",
"ssh2-sftp-client": "^12.1.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/sharp": "^0.31.0",
"@types/ssh2-sftp-client": "^9.0.6",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^4.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,51 @@ program
const create = program.command('create').description('Create resources')

create
.command('bucket <name> <absolutePath>')
.description('Create a new bucket mapped to an absolute filesystem path')
.command('bucket <name> <path>')
.description('Create a new bucket mapped to an absolute path (local filesystem or SFTP)')
.option('--data-dir <dir>', 'Data directory', getDataDir())
.action(async (name: string, absolutePath: string, opts) => {
.option('--storage <target>', 'Storage target: local (default) or sftp', 'local')
.option('--sftp-host <host>', 'SFTP server hostname (required for sftp storage)')
.option('--sftp-port <port>', 'SFTP server port (default: 22)', '22')
.option('--sftp-username <username>', 'SFTP username (required for sftp storage)')
.option('--sftp-password <password>', 'SFTP password')
.option('--sftp-private-key <key>', 'PEM-encoded private key content for SFTP authentication')
.option('--sftp-passphrase <passphrase>', 'Passphrase for an encrypted SFTP private key')
.action(async (name: string, bucketPath: string, opts) => {
const runtime = await createRuntime({ dataDir: opts.dataDir })
try {
const bucket = await runtime.bucketService.create(name, absolutePath)
const storageTarget = opts.storage as 'local' | 'sftp'
let bucket: Bucket

if (storageTarget === 'sftp') {
if (!opts.sftpHost) {
console.error(JSON.stringify({ ok: false, error: { code: 'SFS_INVALID_ARGS', message: '--sftp-host is required for sftp storage' } }))
process.exit(1)
}
if (!opts.sftpUsername) {
console.error(JSON.stringify({ ok: false, error: { code: 'SFS_INVALID_ARGS', message: '--sftp-username is required for sftp storage' } }))
process.exit(1)
}
if (!opts.sftpPassword && !opts.sftpPrivateKey) {
console.error(JSON.stringify({ ok: false, error: { code: 'SFS_INVALID_ARGS', message: 'Either --sftp-password or --sftp-private-key is required for sftp storage' } }))
process.exit(1)
}

bucket = await runtime.bucketService.create(name, bucketPath, 'sftp', {
host: opts.sftpHost as string,
port: parseInt(opts.sftpPort as string, 10),
username: opts.sftpUsername as string,
password: opts.sftpPassword as string | undefined,
privateKey: opts.sftpPrivateKey as string | undefined,
passphrase: opts.sftpPassphrase as string | undefined,
})

// Initialise SFTP bucket (creates remote dir + local staging)
await runtime.storageService.initBucket(bucket)
} else {
bucket = await runtime.bucketService.create(name, bucketPath)
}

console.log(JSON.stringify({ ok: true, data: bucket }, null, 2))
} catch (err: unknown) {
Comment on lines +60 to 93
printError(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export enum SFSErrorCode {
UPLOAD_FAILED = 'SFS_UPLOAD_FAILED',
UPLOAD_STAGING_FAILED = 'SFS_UPLOAD_STAGING_FAILED',

// SFTP
SFTP_CONNECTION_FAILED = 'SFS_SFTP_CONNECTION_FAILED',
SFTP_AUTH_FAILED = 'SFS_SFTP_AUTH_FAILED',
SFTP_OPERATION_FAILED = 'SFS_SFTP_OPERATION_FAILED',

// General
INTERNAL_ERROR = 'SFS_INTERNAL_ERROR',
NOT_IMPLEMENTED = 'SFS_NOT_IMPLEMENTED',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
export { createRuntime, startServer } from './runtime/index.js'
export { BucketService } from './storage/bucket.service.js'
export { StorageService } from './storage/storage.service.js'
export { LocalStorageProvider } from './storage/local.provider.js'
export { SftpStorageProvider } from './storage/sftp.provider.js'
export { StorageProviderRegistry } from './storage/storage.provider.js'
export type { StorageProvider, DownloadResult, UploadOptions } from './storage/storage.provider.js'
export { ThumbnailService } from './thumbnail/thumbnail.service.js'
export { IdentityService } from './auth/identity.service.js'
export { OperationalLogger } from './logging/logger.js'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ensureDataDirs, getDefaultConfig } from '../config/config.service.js'
import path from 'node:path'
import { ensureDataDirs, getDefaultConfig, resolveDataPaths } from '../config/config.service.js'
import { IdentityService } from '../auth/identity.service.js'
import { BucketService } from '../storage/bucket.service.js'
import { StorageService } from '../storage/storage.service.js'
import { SftpStorageProvider } from '../storage/sftp.provider.js'
import { ThumbnailService } from '../thumbnail/thumbnail.service.js'
import { OperationalLogger } from '../logging/logger.js'
import { createServer } from '../http/server.js'
Expand Down Expand Up @@ -29,6 +31,11 @@ export async function createRuntime(overrides?: Partial<SFSConfig>): Promise<SFS
const identityService = new IdentityService(config.dataDir)
const bucketService = new BucketService(config.dataDir)
const storageService = new StorageService(bucketService)

// Register the SFTP provider so SFTP buckets are supported out-of-the-box
const sftpStagingDir = path.join(resolveDataPaths(config.dataDir).cacheDir, 'sftp-staging')
storageService.registerProvider('sftp', new SftpStorageProvider(sftpStagingDir))

const thumbnailService = new ThumbnailService(bucketService, storageService)

return {
Expand All @@ -45,8 +52,8 @@ export async function startServer(overrides?: Partial<SFSConfig>): Promise<SFSRu
const runtime = await createRuntime(overrides)
const { config, bucketService, storageService, thumbnailService, identityService, operationalLogger } = runtime

// Validate buckets on startup
await bucketService.validateStartup()
// Validate buckets on startup (uses StorageService to dispatch to correct provider)
await storageService.validateStartup()

const server = await createServer({
config,
Expand All @@ -64,6 +71,7 @@ export async function startServer(overrides?: Partial<SFSConfig>): Promise<SFSRu
server.log.info(`Received ${signal}, gracefully shutting down...`)
operationalLogger.log({ event: 'server_stop', message: `Shutting down (${signal})` })
await server.close()
await storageService.closeProviders()
await operationalLogger.close()
process.exit(0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'node:path'
import fsp from 'node:fs/promises'
import fs from 'node:fs'
import { resolveDataPaths, readJsonFile, writeJsonFile } from '../config/config.service.js'
import type { Bucket } from '../types/index.js'
import type { Bucket, StorageTarget, SftpConfig } from '../types/index.js'
import { SFSError, SFSErrorCode, notFound } from '../errors/index.js'

const SFS_INTERNAL_DIR = '.sfs'
Expand All @@ -23,32 +23,70 @@ export class BucketService {
return path.join(this.bucketsDir, `${safe}.json`)
}

async create(name: string, absolutePath: string): Promise<Bucket> {
if (!path.isAbsolute(absolutePath)) {
throw new SFSError(SFSErrorCode.BUCKET_PATH_INVALID, `Bucket path must be absolute: ${absolutePath}`, 400)
/**
* Create a new bucket.
*
* @param name Unique bucket name.
* @param bucketPath Absolute path to the storage root.
* For `'local'` this is a local filesystem path.
* For `'sftp'` this is the remote base path on the SFTP server.
* @param storageTarget Storage backend (default: `'local'`).
* @param sftpConfig Required when `storageTarget` is `'sftp'`.
*/
async create(
name: string,
bucketPath: string,
storageTarget: StorageTarget = 'local',
sftpConfig?: SftpConfig,
): Promise<Bucket> {
if (!path.isAbsolute(bucketPath)) {
throw new SFSError(SFSErrorCode.BUCKET_PATH_INVALID, `Bucket path must be absolute: ${bucketPath}`, 400)
}

const existing = await this.find(name)
if (existing) {
throw conflict(`Bucket '${name}' already exists`, SFSErrorCode.BUCKET_ALREADY_EXISTS)
}

if (storageTarget === 'sftp') {
if (!sftpConfig) {
throw new SFSError(
SFSErrorCode.BUCKET_PATH_INVALID,
`sftpConfig is required when storageTarget is 'sftp'`,
400,
)
}

const bucket: Bucket = {
name,
path: bucketPath,
createdAt: new Date().toISOString(),
storageTarget: 'sftp',
sftpConfig,
}

await writeJsonFile(this.bucketFilePath(name), bucket)
return bucket
Comment on lines +51 to +69
}

// ── local storage ──────────────────────────────────────────────────────
Comment on lines +36 to +72

// Ensure bucket dir exists
await fsp.mkdir(absolutePath, { recursive: true })
await fsp.mkdir(bucketPath, { recursive: true })

// Validate writable
try {
await fsp.access(absolutePath, fs.constants.W_OK)
await fsp.access(bucketPath, fs.constants.W_OK)
} catch {
throw new SFSError(SFSErrorCode.BUCKET_PATH_NOT_WRITABLE, `Bucket path is not writable: ${absolutePath}`, 400)
throw new SFSError(SFSErrorCode.BUCKET_PATH_NOT_WRITABLE, `Bucket path is not writable: ${bucketPath}`, 400)
}

// Create internal .sfs directory
await this.ensureBucketInternals(absolutePath)
await this.ensureBucketInternals(bucketPath)

const bucket: Bucket = {
name,
path: absolutePath,
path: bucketPath,
createdAt: new Date().toISOString(),
}

Expand Down Expand Up @@ -94,6 +132,7 @@ export class BucketService {

/**
* Resolve a key to an absolute filesystem path within the bucket.
* Only valid for `'local'` storage target buckets.
* Validates against path traversal attacks.
*/
resolveKey(bucket: Bucket, key: string): string {
Expand Down Expand Up @@ -139,38 +178,5 @@ export class BucketService {
getThumbsDir(bucket: Bucket): string {
return path.join(bucket.path, SFS_INTERNAL_DIR, 'thumbs')
}

/**
* Validate startup: check all buckets are accessible and writable, clean orphan staging files.
*/
async validateStartup(): Promise<void> {
const buckets = await this.list()
for (const bucket of buckets) {
try {
await fsp.access(bucket.path, fs.constants.W_OK)
await this.ensureBucketInternals(bucket.path)
await this.cleanOrphanStagingFiles(bucket)
} catch (err) {
console.warn(`[SFS] Warning: bucket '${bucket.name}' at '${bucket.path}' is not accessible:`, err)
}
}
}

private async cleanOrphanStagingFiles(bucket: Bucket): Promise<void> {
const stagingDir = this.getStagingDir(bucket)
try {
const files = await fsp.readdir(stagingDir)
const cutoff = Date.now() - 60 * 60 * 1000 // older than 1 hour
for (const file of files) {
const filePath = path.join(stagingDir, file)
const stat = await fsp.stat(filePath)
if (stat.mtimeMs < cutoff) {
await fsp.unlink(filePath).catch(() => {})
}
}
} catch {
// Ignore cleanup errors
}
}
}

Loading