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:
23
packages/backend/src/storage/index.ts
Normal file
23
packages/backend/src/storage/index.ts
Normal 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}`)
|
||||
}
|
||||
48
packages/backend/src/storage/local.ts
Normal file
48
packages/backend/src/storage/local.ts
Normal 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)}`
|
||||
}
|
||||
}
|
||||
7
packages/backend/src/storage/provider.ts
Normal file
7
packages/backend/src/storage/provider.ts
Normal 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>
|
||||
}
|
||||
47
packages/backend/src/storage/s3.ts
Normal file
47
packages/backend/src/storage/s3.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user