diff --git a/.env.example b/.env.example index 6cf18856..b470eabd 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,16 @@ DATABASE_URL= # Redis REDIS_URL= +# Object storage (S3-compatible). Defaults below target local MinIO from +# infra/docker-compose.yml. For AWS S3 or Cloudflare R2, swap endpoint, +# credentials, region, and set OBJECT_STORE_FORCE_PATH_STYLE=false. +OBJECT_STORE_ENDPOINT=http://localhost:9000 +OBJECT_STORE_BUCKET=clicked +OBJECT_STORE_ACCESS_KEY=clicked +OBJECT_STORE_SECRET_KEY=clickedsecret +OBJECT_STORE_REGION=us-east-1 +OBJECT_STORE_FORCE_PATH_STYLE=true + # AI Service OPENAI_API_KEY= diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts index cf4a381c..5dbe8a17 100644 --- a/apps/backend/src/__tests__/config.test.ts +++ b/apps/backend/src/__tests__/config.test.ts @@ -7,6 +7,12 @@ const validEnv = { JWT_SECRET: 'test-secret', PORT: '3001', TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123', + OBJECT_STORE_ENDPOINT: 'http://localhost:9000', + OBJECT_STORE_BUCKET: 'clicked', + OBJECT_STORE_ACCESS_KEY: 'clicked', + OBJECT_STORE_SECRET_KEY: 'clickedsecret', + OBJECT_STORE_REGION: 'us-east-1', + OBJECT_STORE_FORCE_PATH_STYLE: 'true', }; describe('loadEnv', () => { @@ -26,6 +32,12 @@ describe('loadEnv', () => { JWT_SECRET: 'test-secret', PORT: 3001, TOKEN_TRANSFER_CONTRACT_ID: 'CONTRACT123', + OBJECT_STORE_ENDPOINT: 'http://localhost:9000', + OBJECT_STORE_BUCKET: 'clicked', + OBJECT_STORE_ACCESS_KEY: 'clicked', + OBJECT_STORE_SECRET_KEY: 'clickedsecret', + OBJECT_STORE_REGION: 'us-east-1', + OBJECT_STORE_FORCE_PATH_STYLE: true, }); expect(errorSpy).not.toHaveBeenCalled(); expect(logSpy).not.toHaveBeenCalled(); @@ -78,4 +90,15 @@ describe('loadEnv', () => { const parsed = EnvSchema.parse({ ...validEnv, PORT: '8080' }); expect(parsed.PORT).toBe(8080); }); + + it('coerces OBJECT_STORE_FORCE_PATH_STYLE from string to boolean', () => { + expect( + EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'false' }) + .OBJECT_STORE_FORCE_PATH_STYLE, + ).toBe(false); + expect( + EnvSchema.parse({ ...validEnv, OBJECT_STORE_FORCE_PATH_STYLE: 'true' }) + .OBJECT_STORE_FORCE_PATH_STYLE, + ).toBe(true); + }); }); diff --git a/apps/backend/src/__tests__/objectStore.test.ts b/apps/backend/src/__tests__/objectStore.test.ts new file mode 100644 index 00000000..c1ccd1c8 --- /dev/null +++ b/apps/backend/src/__tests__/objectStore.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { createObjectStore, createObjectStoreClient } from '../lib/objectStore.js'; + +const config = { + OBJECT_STORE_ENDPOINT: 'http://localhost:9000', + OBJECT_STORE_BUCKET: 'clicked', + OBJECT_STORE_ACCESS_KEY: 'clicked', + OBJECT_STORE_SECRET_KEY: 'clickedsecret', + OBJECT_STORE_REGION: 'us-east-1', + OBJECT_STORE_FORCE_PATH_STYLE: true, +}; + +describe('createObjectStoreClient', () => { + it('configures the S3 client for path-style MinIO endpoints', () => { + const client = createObjectStoreClient(config); + expect(client).toBeInstanceOf(S3Client); + expect(client.config.endpoint).toBeDefined(); + }); + + it('supports virtual-hosted AWS/R2 style endpoints when path style is disabled', () => { + const client = createObjectStoreClient({ + ...config, + OBJECT_STORE_ENDPOINT: 'https://s3.amazonaws.com', + OBJECT_STORE_FORCE_PATH_STYLE: false, + }); + expect(client).toBeInstanceOf(S3Client); + }); +}); + +describe('ObjectStore', () => { + const send = vi.fn(); + + beforeEach(() => { + send.mockReset(); + vi.spyOn(S3Client.prototype, 'send').mockImplementation(send); + }); + + it('checks bucket reachability with HeadBucket', async () => { + send.mockResolvedValue({}); + const store = createObjectStore(config); + + await store.ensureBucketReachable(); + + expect(send).toHaveBeenCalledWith(expect.any(HeadBucketCommand)); + }); + + it('uploads, reads, and deletes objects in the configured bucket', async () => { + send.mockResolvedValue({}); + const store = createObjectStore(config); + + await store.putObject('avatars/user.png', Buffer.from('png'), 'image/png'); + await store.getObject('avatars/user.png'); + await store.deleteObject('avatars/user.png'); + + expect(send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: expect.objectContaining({ + Bucket: 'clicked', + Key: 'avatars/user.png', + ContentType: 'image/png', + }), + }), + ); + expect(send).toHaveBeenNthCalledWith(2, expect.any(GetObjectCommand)); + expect(send).toHaveBeenNthCalledWith(3, expect.any(DeleteObjectCommand)); + }); +}); diff --git a/apps/backend/src/__tests__/setup.ts b/apps/backend/src/__tests__/setup.ts index d48b6703..da15208d 100644 --- a/apps/backend/src/__tests__/setup.ts +++ b/apps/backend/src/__tests__/setup.ts @@ -1,2 +1,8 @@ process.env['JWT_SECRET'] = 'test-secret-for-ci-only'; process.env['DATABASE_URL'] = 'postgres://localhost/test'; +process.env['OBJECT_STORE_ENDPOINT'] = 'http://localhost:9000'; +process.env['OBJECT_STORE_BUCKET'] = 'clicked'; +process.env['OBJECT_STORE_ACCESS_KEY'] = 'clicked'; +process.env['OBJECT_STORE_SECRET_KEY'] = 'clickedsecret'; +process.env['OBJECT_STORE_REGION'] = 'us-east-1'; +process.env['OBJECT_STORE_FORCE_PATH_STYLE'] = 'true'; diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index ce53b6c6..cd713b4a 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +const booleanEnv = z + .enum(['true', 'false', '1', '0']) + .transform((value) => value === 'true' || value === '1'); + /** * Startup environment schema. Every variable here is required for the * backend to boot; `loadEnv` validates `process.env` against it and exits @@ -11,6 +15,12 @@ export const EnvSchema = z.object({ JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), PORT: z.coerce.number().int('PORT must be an integer').positive('PORT must be positive'), TOKEN_TRANSFER_CONTRACT_ID: z.string().min(1, 'TOKEN_TRANSFER_CONTRACT_ID is required'), + OBJECT_STORE_ENDPOINT: z.string().min(1, 'OBJECT_STORE_ENDPOINT is required'), + OBJECT_STORE_BUCKET: z.string().min(1, 'OBJECT_STORE_BUCKET is required'), + OBJECT_STORE_ACCESS_KEY: z.string().min(1, 'OBJECT_STORE_ACCESS_KEY is required'), + OBJECT_STORE_SECRET_KEY: z.string().min(1, 'OBJECT_STORE_SECRET_KEY is required'), + OBJECT_STORE_REGION: z.string().min(1, 'OBJECT_STORE_REGION is required'), + OBJECT_STORE_FORCE_PATH_STYLE: booleanEnv, }); export type Env = z.infer; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 2f5c6fa8..e1cbfe70 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -35,12 +35,14 @@ import { } from './services/stellarListener.js'; import { startFileCleanupJob } from './services/fileCleanup.js'; import { loadEnv } from './config.js'; +import { createObjectStore } from './lib/objectStore.js'; dotenv.config(); // Validate required environment variables at boot. Exits with code 1 and // logs the offending vars if anything is missing or malformed. -loadEnv(); +const env = loadEnv(); +export const objectStore = createObjectStore(env); const httpServer = createServer(app); const io = new Server(httpServer, { diff --git a/apps/backend/src/lib/objectStore.ts b/apps/backend/src/lib/objectStore.ts new file mode 100644 index 00000000..726f07e2 --- /dev/null +++ b/apps/backend/src/lib/objectStore.ts @@ -0,0 +1,80 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, + type PutObjectCommandInput, +} from '@aws-sdk/client-s3'; +import type { Env } from '../config.js'; + +export type ObjectStoreConfig = Pick< + Env, + | 'OBJECT_STORE_ENDPOINT' + | 'OBJECT_STORE_BUCKET' + | 'OBJECT_STORE_ACCESS_KEY' + | 'OBJECT_STORE_SECRET_KEY' + | 'OBJECT_STORE_REGION' + | 'OBJECT_STORE_FORCE_PATH_STYLE' +>; + +/** + * Build an S3-compatible client from env. The same configuration works against + * local MinIO (path-style + custom endpoint), AWS S3, and Cloudflare R2 — + * only the env values change. + */ +export function createObjectStoreClient(config: ObjectStoreConfig): S3Client { + return new S3Client({ + endpoint: config.OBJECT_STORE_ENDPOINT, + region: config.OBJECT_STORE_REGION, + credentials: { + accessKeyId: config.OBJECT_STORE_ACCESS_KEY, + secretAccessKey: config.OBJECT_STORE_SECRET_KEY, + }, + forcePathStyle: config.OBJECT_STORE_FORCE_PATH_STYLE, + }); +} + +export class ObjectStore { + constructor( + private readonly client: S3Client, + private readonly bucket: string, + ) {} + + async ensureBucketReachable(): Promise { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); + } + + async putObject(key: string, body: NonNullable, contentType?: string) { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ...(contentType ? { ContentType: contentType } : {}), + }), + ); + } + + async getObject(key: string) { + return this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } + + async deleteObject(key: string) { + await this.client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } +} + +export function createObjectStore(config: ObjectStoreConfig): ObjectStore { + return new ObjectStore(createObjectStoreClient(config), config.OBJECT_STORE_BUCKET); +} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 5ef96d85..924e0856 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -37,6 +37,43 @@ services: retries: 5 start_period: 2s + # #223 — S3-compatible object storage (MinIO). Swap env vars to target AWS S3 + # or Cloudflare R2 in production; the backend uses the same S3 client path. + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: clicked + MINIO_ROOT_PASSWORD: clickedsecret + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + # Create the application bucket on first boot with a private ACL (no anonymous + # read). Re-runs are idempotent via --ignore-existing. + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 clicked clickedsecret && + mc mb --ignore-existing local/clicked && + mc anonymous set none local/clicked + " + restart: "no" + volumes: postgres_data: redis_data: + minio_data: