Implement file storage layer with local provider, upload/download API, tests

- StorageProvider interface with LocalProvider (S3 placeholder)
- File table with entity_type/entity_id references, content type, path
- POST /v1/files (multipart upload), GET /v1/files (list by entity),
  GET /v1/files/:id (metadata), GET /v1/files/serve/* (content),
  DELETE /v1/files/:id
- member_identifier drops base64 columns, uses file_id FKs
- File validation: type whitelist, size limits, per-entity max
- Fastify storage plugin injects provider into app
- 6 API tests for upload, list, get, delete, validation
- Test runner kills stale port before starting backend
This commit is contained in:
Ryan Moon
2026-03-28 15:29:06 -05:00
parent de4d2e0a32
commit 760e995ae3
19 changed files with 615 additions and 6 deletions

View File

@@ -0,0 +1,23 @@
import { LocalStorageProvider } from './local.js'
import type { StorageProvider } from './provider.js'
export type { StorageProvider }
export function createStorageProvider(): StorageProvider {
const provider = process.env.STORAGE_PROVIDER ?? 'local'
if (provider === 'local') {
const root = process.env.STORAGE_LOCAL_PATH ?? './data/files'
const baseUrl = `http://localhost:${process.env.PORT ?? '8000'}`
return new LocalStorageProvider(root, baseUrl)
}
if (provider === 's3') {
// Lazy import to avoid requiring @aws-sdk when using local
throw new Error(
'S3 provider requires @aws-sdk/client-s3. Install it and update this factory.',
)
}
throw new Error(`Unknown storage provider: ${provider}`)
}

View File

@@ -0,0 +1,48 @@
import { mkdir, readFile, writeFile, unlink, access } from 'fs/promises'
import { dirname, join } from 'path'
import type { StorageProvider } from './provider.js'
export class LocalStorageProvider implements StorageProvider {
private root: string
private baseUrl: string
constructor(root: string, baseUrl: string) {
this.root = root
this.baseUrl = baseUrl
}
private fullPath(path: string): string {
return join(this.root, path)
}
async put(path: string, data: Buffer, _contentType: string): Promise<void> {
const fullPath = this.fullPath(path)
await mkdir(dirname(fullPath), { recursive: true })
await writeFile(fullPath, data)
}
async get(path: string): Promise<Buffer> {
return readFile(this.fullPath(path))
}
async delete(path: string): Promise<void> {
try {
await unlink(this.fullPath(path))
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
async exists(path: string): Promise<boolean> {
try {
await access(this.fullPath(path))
return true
} catch {
return false
}
}
async getUrl(path: string, _expiresIn?: number): Promise<string> {
return `${this.baseUrl}/v1/files/serve/${encodeURIComponent(path)}`
}
}

View File

@@ -0,0 +1,7 @@
export interface StorageProvider {
put(path: string, data: Buffer, contentType: string): Promise<void>
get(path: string): Promise<Buffer>
delete(path: string): Promise<void>
exists(path: string): Promise<boolean>
getUrl(path: string, expiresIn?: number): Promise<string>
}

View File

@@ -0,0 +1,47 @@
import type { StorageProvider } from './provider.js'
// S3 provider — requires @aws-sdk/client-s3 (install when needed)
// This is a placeholder that documents the interface. Install the SDK
// and uncomment when deploying with S3.
export class S3StorageProvider implements StorageProvider {
private bucket: string
private region: string
private endpoint?: string
constructor(config: {
bucket: string
region: string
endpoint?: string
accessKey: string
secretKey: string
}) {
this.bucket = config.bucket
this.region = config.region
this.endpoint = config.endpoint
// TODO: initialize S3Client from @aws-sdk/client-s3
throw new Error(
'S3 provider not yet implemented. Install @aws-sdk/client-s3 and implement.',
)
}
async put(_path: string, _data: Buffer, _contentType: string): Promise<void> {
throw new Error('Not implemented')
}
async get(_path: string): Promise<Buffer> {
throw new Error('Not implemented')
}
async delete(_path: string): Promise<void> {
throw new Error('Not implemented')
}
async exists(_path: string): Promise<boolean> {
throw new Error('Not implemented')
}
async getUrl(_path: string, _expiresIn?: number): Promise<string> {
throw new Error('Not implemented')
}
}