diff --git a/.gitignore b/.gitignore index 996fc0e..ec53828 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ yarn-error.log* **/*.tgz **/*.log package-lock.json -**/*.bun \ No newline at end of file +**/*.bun + +./server +./.storage_data diff --git a/Dockerfile b/Dockerfile index 36cc29e..a1c63b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ RUN bun install --frozen-lockfile # Copy source code COPY . . -# Build the application -RUN bun build ./src/index.ts --compile --outfile server +# RUN DB Migrations and build +RUN bun run db:migrateb && bun run build # Production stage FROM debian:bookworm-slim @@ -28,4 +28,4 @@ COPY --from=builder /app/server . EXPOSE 3000 # Run the binary -CMD ["./server"] \ No newline at end of file +CMD ["./server"] diff --git a/bun.lockb b/bun.lockb index b1220bf..1872bc5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker-compose.yaml b/docker-compose.yaml index df6470f..d8c86f1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,13 +5,11 @@ services: dockerfile: Dockerfile ports: - "3000:3000" - env_file: - - .env environment: - - NODE_ENV=production - - OTEL_EXPORTER_OTLP_ENDPOINT=http://tracing:4318 - - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf - - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} + NODE_ENV: production + OTEL_EXPORTER_OTLP_ENDPOINT: http://tracing:4318 + OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf + DATABASE_URL: ${DATABASE_URL} networks: - api-network depends_on: @@ -20,8 +18,8 @@ services: tracing: image: jaegertracing/all-in-one:latest environment: - - COLLECTOR_ZIPKIN_HOST_PORT=:9411 - - COLLECTOR_OTLP_ENABLED=true + COLLECTOR_ZIPKIN_HOST_PORT: 9411 + COLLECTOR_OTLP_ENABLED: true ports: - "16686:16686" networks: @@ -42,9 +40,31 @@ services: networks: - api-network + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - api-network + networks: api-network: driver: bridge volumes: postgres_data: + minio_data: diff --git a/package.json b/package.json index f5fa11e..084b80a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "bun run --watch src/index.ts", "email": "email dev --dir src/emails", "db:studio": "drizzle-kit studio", + "db:check": "drizzle-kit check", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile server ./src/index.ts" @@ -22,6 +23,7 @@ "drizzle-orm": "^0.38.3", "drizzle-typebox": "^0.2.1", "elysia": "latest", + "minio": "^8.0.3", "nodemailer": "^6.9.16", "pg": "^8.13.1", "react": "^19.0.0", diff --git a/src/index.ts b/src/index.ts index 669072b..5c23788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { cors } from '@elysiajs/cors' import { note } from "./api/note/note.route"; import { betterAuthView } from "./lib/auth/auth-view"; import { userMiddleware, userInfo } from "./middlewares/auth-middleware"; +import { validateEnv } from "./lib/utils/env"; const app = new Elysia() .use(cors()) @@ -23,6 +24,6 @@ const app = new Elysia() .use(note) .get("/user", ({ user, session }) => userInfo(user, session)); +validateEnv(); app.listen(3000); - console.log("Server is running on: http://localhost:3000") diff --git a/src/lib/storage/s3.ts b/src/lib/storage/s3.ts new file mode 100644 index 0000000..c9e3648 --- /dev/null +++ b/src/lib/storage/s3.ts @@ -0,0 +1,143 @@ +import { Client } from 'minio'; +import { Buffer } from 'buffer'; +import { getMinioConfig } from '../utils/env'; + +// MinIO client configuration + +const minioConfig = getMinioConfig() +const minioClient = new Client({ + endPoint: minioConfig.MINIO_ENDPOINT_URL, + useSSL: false, + accessKey: minioConfig.MINIO_ACCESS_KEY, + secretKey: minioConfig.MINIO_SECRET_KEY +}); + +const BUCKET_NAME = minioConfig.MINIO_BUCKET_NAME; +const SIGNED_URL_EXPIRY = 24 * 60 * 60; // 24 hours in seconds + +// Ensure bucket exists +const ensureBucket = async (): Promise => { + const bucketExists = await minioClient.bucketExists(BUCKET_NAME); + if (!bucketExists) { + await minioClient.makeBucket(BUCKET_NAME); + } +}; + +/** + * Convert File/Blob to Buffer + * @param file - File or Blob object + * @returns Promise + */ +const fileToBuffer = async (file: File | Blob): Promise => { + const arrayBuffer = await file.arrayBuffer(); + return Buffer.from(arrayBuffer); +}; + +/** + * Upload a file to MinIO and return its signed URL + * Supports both Buffer and File/Blob (from multipart form-data) + * @param filename - The name to store the file as + * @param file - The file to upload (Buffer, File, or Blob) + * @param contentType - Optional MIME type of the file + * @returns Promise with the signed URL of the uploaded file + */ +export const uploadFileAndGetUrl = async ( + filename: string, + file: Buffer | File | Blob, + contentType?: string +): Promise => { + try { + await ensureBucket(); + + // Convert File/Blob to Buffer if needed + const fileBuffer = Buffer.isBuffer(file) ? file : await fileToBuffer(file); + + // If file is from form-data and no contentType is provided, use its type + const metadata: Record = {}; + if (!contentType && 'type' in file) { + contentType = file.type; + } + if (contentType) { + metadata['Content-Type'] = contentType; + } + + // Upload the file + await minioClient.putObject( + BUCKET_NAME, + filename, + fileBuffer, + fileBuffer.length, + metadata + ); + + // Generate and return signed URL + const url = await minioClient.presignedGetObject( + BUCKET_NAME, + filename, + SIGNED_URL_EXPIRY + ); + + return url; + } catch (error) { + console.error('Error uploading file:', error); + if (error instanceof Error) { + throw new Error(`Failed to upload file: ${error.message}`); + } + throw new Error(`Failed to upload file: ${error}`); + } +}; + +/** + * Get a signed URL for an existing file + * @param filename - The name of the file to get URL for + * @returns Promise with the signed URL + */ +export const getSignedUrl = async (filename: string): Promise => { + try { + // Check if file exists first + await minioClient.statObject(BUCKET_NAME, filename); + + const url = await minioClient.presignedGetObject( + BUCKET_NAME, + filename, + SIGNED_URL_EXPIRY + ); + + return url; + } catch (error) { + console.error('Error generating signed URL:', error); + if (error instanceof Error) throw new Error(`Failed to generate signed URL: ${error.message}`); + throw new Error(`Failed to generate signed URL: ${error}`); + } +}; + +/** + * Delete a file from MinIO + * @param filename - The name of the file to delete + * @returns Promise + */ +export const deleteFile = async (filename: string): Promise => { + try { + await minioClient.removeObject(BUCKET_NAME, filename); + } catch (error) { + console.error('Error deleting file:', error); + if (error instanceof Error) throw new Error(`Failed to delete file: ${error.message}`); + throw new Error(`Failed to delete file: ${error}`); + } +}; + +// Usage examples: + +// Upload a file and get its URL +// const fileBuffer = Buffer.from('Hello World'); +// const url = await uploadFileAndGetUrl('hello.txt', fileBuffer); +// console.log('Uploaded file URL:', url); + +// Get signed URL for existing file +// const url = await getSignedUrl('hello.txt'); +// console.log('Signed URL:', url); + +// Delete a file +// await deleteFile('hello.txt'); +// console.log('File deleted successfully'); + diff --git a/src/lib/utils/env.ts b/src/lib/utils/env.ts new file mode 100644 index 0000000..5bffa5c --- /dev/null +++ b/src/lib/utils/env.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +// Define the environment schema +const envSchema = z.object({ + // Database + DATABASE_URL: z.string().url(), + + // MinIO + MINIO_ACCESS_KEY: z.string(), + MINIO_SECRET_KEY: z.string().min(8), + MINIO_ENDPOINT_URL: z.string().url(), + MINIO_BUCKET_NAME: z.string().url(), +}); + +// Create a type from the schema +type EnvConfig = z.infer; + +/** + * Validates environment variables and returns a typed config object + * Prints warnings for missing or invalid variables + */ +export const validateEnv = (): EnvConfig => { + const warnings: string[] = []; + + // Parse environment with warning collection + const config = envSchema.safeParse(process.env); + + if (!config.success) { + console.warn('\n🚨 Environment Variable Warnings:'); + + // Collect and categorize warnings + config.error.errors.forEach((error) => { + const path = error.path.join('.'); + const message = error.message; + + let warningMessage = `❌ ${path}: ${message}`; + + // Add specific functionality warnings + if (path.startsWith('DB_') || path === 'DATABASE_URL') { + warningMessage += '\n ⚠️ Database functionality may not work properly'; + } + if (path.startsWith('MINIO_')) { + warningMessage += '\n ⚠️ File storage functionality may not work properly'; + } + + warnings.push(warningMessage); + }); + + // Print all warnings + warnings.forEach((warning) => console.warn(warning)); + console.warn('\n'); + + throw new Error('Environment validation failed. Check warnings above.'); + } + + return config.data; +}; + +/** + * Get validated environment config + * Throws error if validation fails + */ +export const getConfig = (): EnvConfig => { + return validateEnv(); +}; + +// Optional: Export individual config getters with type safety +export const getDbConfig = (): Pick => { + const config = getConfig(); + return { + DATABASE_URL: config.DATABASE_URL, + }; +}; + +export const getMinioConfig = (): Pick => { + const config = getConfig(); + return { + MINIO_ACCESS_KEY: config.MINIO_ACCESS_KEY, + MINIO_SECRET_KEY: config.MINIO_SECRET_KEY, + MINIO_ENDPOINT_URL: config.MINIO_ENDPOINT_URL, + MINIO_BUCKET_NAME: config.MINIO_BUCKET_NAME, + }; +}; + +// Usage example: +try { + const config = getConfig(); + // Your application code here +} catch (error) { + // Handle validation errors + process.exit(1); +}