- Updated README and documentation to reflect the transition from MinIO to Garage for S3-compatible storage. - Removed the MinIO-specific docker-compose files and replaced them with Garage configurations. - Adjusted environment variables and service dependencies in docker-compose files to accommodate Garage. - Modified application code to log hints for Garage instead of MinIO. - Ensured all references to MinIO in the codebase and documentation are replaced with Garage.
173 lines
5.1 KiB
TypeScript
173 lines
5.1 KiB
TypeScript
import "server-only";
|
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
import path from "path";
|
|
|
|
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
|
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
|
|
|
function isS3Configured(): boolean {
|
|
return Boolean(
|
|
process.env.S3_BUCKET &&
|
|
process.env.S3_ACCESS_KEY &&
|
|
process.env.S3_SECRET_KEY,
|
|
);
|
|
}
|
|
|
|
export function getStorageBackend(): "s3" | "local" {
|
|
return isS3Configured() ? "s3" : "local";
|
|
}
|
|
|
|
type S3Module = typeof import("@aws-sdk/client-s3");
|
|
|
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
|
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
|
let s3DnsHintLogged = false;
|
|
let s3BareGarageHintLogged = false;
|
|
|
|
function shouldForcePathStyle(): boolean {
|
|
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
|
|
if (override === "true" || override === "1") return true;
|
|
if (override === "false" || override === "0") return false;
|
|
return Boolean(process.env.S3_ENDPOINT);
|
|
}
|
|
|
|
function logBareGarageEndpointHint(): void {
|
|
if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return;
|
|
const endpoint = process.env.S3_ENDPOINT;
|
|
if (!endpoint) return;
|
|
try {
|
|
const { hostname } = new URL(endpoint);
|
|
if (hostname !== "garage") return;
|
|
s3BareGarageHintLogged = true;
|
|
console.warn(
|
|
"[object-storage] S3_ENDPOINT hostname is bare 'garage'. " +
|
|
"That only resolves inside a single Docker Compose stack. " +
|
|
"Coolify Application + separate Garage compose: set S3_ENDPOINT to " +
|
|
"SERVICE_URL_GARAGE_3900 (public domain) or http://garage-<resource-uuid>:3900. " +
|
|
"See docs/COOLIFY.md.",
|
|
);
|
|
} catch {
|
|
// Invalid URL — env validation or S3 client will surface it.
|
|
}
|
|
}
|
|
|
|
function logS3DnsHint(error: unknown): void {
|
|
if (s3DnsHintLogged) return;
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
|
|
s3DnsHintLogged = true;
|
|
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
|
|
console.error(
|
|
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
|
|
"Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
|
|
);
|
|
}
|
|
|
|
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
logS3DnsHint(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getS3() {
|
|
s3ModulePromise ??= import("@aws-sdk/client-s3");
|
|
const mod = await s3ModulePromise;
|
|
if (!s3Client) {
|
|
logBareGarageEndpointHint();
|
|
s3Client = new mod.S3Client({
|
|
region: process.env.S3_REGION ?? "us-east-1",
|
|
endpoint: process.env.S3_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
|
},
|
|
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
|
|
forcePathStyle: shouldForcePathStyle(),
|
|
});
|
|
}
|
|
return { client: s3Client, ...mod };
|
|
}
|
|
|
|
function localPathForKey(key: string) {
|
|
return path.join(LOCAL_RECEIPTS_DIR, key);
|
|
}
|
|
|
|
export async function putObject(
|
|
key: string,
|
|
body: Buffer,
|
|
contentType: string,
|
|
): Promise<void> {
|
|
if (isS3Configured()) {
|
|
const { client, PutObjectCommand } = await getS3();
|
|
await withS3Diagnostics(() =>
|
|
client.send(
|
|
new PutObjectCommand({
|
|
Bucket: process.env.S3_BUCKET!,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
}),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const filePath = localPathForKey(key);
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, body);
|
|
}
|
|
|
|
export async function getObject(key: string): Promise<Buffer> {
|
|
if (isS3Configured()) {
|
|
const { client, GetObjectCommand } = await getS3();
|
|
const response = await withS3Diagnostics(() =>
|
|
client.send(
|
|
new GetObjectCommand({
|
|
Bucket: process.env.S3_BUCKET!,
|
|
Key: key,
|
|
}),
|
|
),
|
|
);
|
|
const bytes = await response.Body?.transformToByteArray();
|
|
if (!bytes) {
|
|
throw new Error("Empty object body");
|
|
}
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
return readFile(localPathForKey(key));
|
|
}
|
|
|
|
export async function deleteObject(key: string): Promise<void> {
|
|
if (isS3Configured()) {
|
|
const { client, DeleteObjectCommand } = await getS3();
|
|
await withS3Diagnostics(() =>
|
|
client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: process.env.S3_BUCKET!,
|
|
Key: key,
|
|
}),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await unlink(localPathForKey(key));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
|
|
|
|
export function isAllowedReceiptMime(mimeType: string): boolean {
|
|
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
return normalized === "application/pdf" || normalized.startsWith("image/");
|
|
}
|