/** * MinIO / S3-compatible object storage client. * * Env vars (set in .env.local): * MINIO_ENDPOINT — host:port, e.g. s3.crispygoat.com (no https://) * MINIO_REGION — e.g. us-east-1 (default) * MINIO_ACCESS_KEY — MinIO access key * MINIO_SECRET_KEY — MinIO secret key * MINIO_USE_SSL — "true" to use HTTPS (default: true if endpoint ends in .com/.io/etc.) * MINIO_BUCKET_PRODUCTS — bucket for product images * MINIO_BUCKET_BRAND_LOGOS — bucket for brand logos * MINIO_BUCKET_WATER_LOGS — bucket for water log photos * * Public URL base — used to construct public-facing URLs after upload: * MINIO_PUBLIC_URL — e.g. https://s3.crispygoat.com (default derived from endpoint) */ import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; // ── Client singleton ───────────────────────────────────────────────────────── let _client: S3Client | null = null; export function getStorageClient(): S3Client { if (_client) return _client; const endpoint = process.env.MINIO_ENDPOINT ?? "s3.crispygoat.com"; const ssl = process.env.MINIO_USE_SSL !== "false"; const region = process.env.MINIO_REGION ?? "us-east-1"; _client = new S3Client({ endpoint: `${ssl ? "https" : "http"}://${endpoint}`, region, credentials: { accessKeyId: process.env.MINIO_ACCESS_KEY ?? "", secretAccessKey: process.env.MINIO_SECRET_KEY ?? "", }, forcePathStyle: true, // Required for MinIO — bucket is not part of the host }); return _client; } // ── Public URL helper ───────────────────────────────────────────────────────── /** * Returns the public base URL for the given bucket. * Override with MINIO_PUBLIC_URL if your public endpoint differs from the endpoint. */ export function getPublicUrl(bucket: string, key: string): string { const base = process.env.MINIO_PUBLIC_URL ?? `https://${process.env.MINIO_ENDPOINT ?? "s3.crispygoat.com"}`; return `${base}/${bucket}/${key}`; } // ── Upload helpers ─────────────────────────────────────────────────────────── export type UploadOptions = { bucket: string; key: string; body: Buffer | Uint8Array; contentType: string; /** * Cache-Control value. Defaults to "public, max-age=31536000, immutable" * for immutable uploaded assets. */ cacheControl?: string; }; export async function uploadObject(opts: UploadOptions): Promise { const client = getStorageClient(); await client.send( new PutObjectCommand({ Bucket: opts.bucket, Key: opts.key, Body: opts.body, ContentType: opts.contentType, CacheControl: opts.cacheControl ?? "public, max-age=31536000, immutable", }) ); return getPublicUrl(opts.bucket, opts.key); } export async function deleteObject(bucket: string, key: string): Promise { const client = getStorageClient(); await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); } // ── Presigned URL helpers ───────────────────────────────────────────────────── /** * Generate a short-lived presigned PUT URL for direct browser → MinIO upload. * Useful when you want the browser to upload bytes directly without routing * them through the Next.js server. * * @param bucket Target bucket name * @param key Desired object key * @param contentType Expected MIME type * @param expiresSeconds URL expiry (default 5 minutes) */ export async function getPresignedPutUrl( bucket: string, key: string, contentType: string, expiresSeconds = 300 ): Promise { const client = getStorageClient(); return getSignedUrl( client, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }), { expiresIn: expiresSeconds } ); } /** * Generate a short-lived presigned GET URL for private objects. * @param bucket Bucket name * @param key Object key * @param expiresSeconds URL expiry (default 1 hour) */ export async function getPresignedGetUrl( bucket: string, key: string, expiresSeconds = 3600 ): Promise { const client = getStorageClient(); return getSignedUrl( client, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn: expiresSeconds } ); } // ── Bucket constants ───────────────────────────────────────────────────────── export const BUCKETS = { PRODUCTS: process.env.MINIO_BUCKET_PRODUCTS ?? "route-products", BRAND_LOGOS: process.env.MINIO_BUCKET_BRAND_LOGOS ?? "route-brand-logos", WATER_LOGS: process.env.MINIO_BUCKET_WATER_LOGS ?? "route-water-logs", } as const;