// services/s3.service.js // // S3-compatible storage service targeting Garage (self-hosted). // Exposes the same interface as chibisafe.service.js: // uploadFile({ buffer, originalname, mimetype, ownerType }) → { url, uuid } // deleteFile(key) // // Required .env vars: // S3_ENDPOINT – http://127.0.0.1:3900 (internal — always used for uploads/deletes) // S3_REGION – garage // S3_ACCESS_KEY // S3_SECRET_KEY // S3_BUCKET – your-bucket-name // S3_PUBLIC_URL – https://cdn.yourdomain.com (used for browser-facing URLs // whenever S3_ENDPOINT isn't reachable from this machine — // see resolvePublicHost() below) const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const crypto = require("crypto"); const path = require("path"); // ─── Clients ────────────────────────────────────────────────────────────────── const credentials = { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY, }; // Internal client — uploads, deletes, direct streams from the server itself. // Always targets S3_ENDPOINT: these calls originate from this machine, so the // internal address is the correct (and only) one to use. const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || "garage", credentials, forcePathStyle: true, }); const DEFAULT_BUCKET = process.env.S3_BUCKET; // ─── Public host resolution ─────────────────────────────────────────────────── // // URLs handed to browsers (file_url, presigned GET links) need a host reachable // from wherever the client sits. S3_ENDPOINT (e.g. 127.0.0.1:3900) only works // from the machine running Garage itself; S3_PUBLIC_URL is the externally // reachable address (tunnel/CDN/domain). // // Rather than always preferring one, probe S3_ENDPOINT and use it when it's // actually reachable (same-machine dev setup — no extra hop through the // tunnel), falling back to S3_PUBLIC_URL when it isn't (any other machine). // // The probe runs once at startup and then on a background timer — never on // the request path itself. A machine without Garage would otherwise pay the // full HeadBucket timeout on whichever upload/asset request happens to land // right after the cache expires; polling in the background means every // request just reads the last known-good host instantly. const PROBE_TIMEOUT_MS = 1500; const PROBE_CACHE_MS = 15000; let hostCache = { host: process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "" }; async function probeEndpoint(endpoint) { const probe = new S3Client({ endpoint, region: process.env.S3_REGION || "garage", credentials, forcePathStyle: true, }); await Promise.race([ probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })), new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)), ]); } async function refreshHostCache() { const endpoint = process.env.S3_ENDPOINT; const publicUrl = process.env.S3_PUBLIC_URL || ""; if (!endpoint) { hostCache = { host: publicUrl }; return; } if (!publicUrl) { hostCache = { host: endpoint }; return; } try { await probeEndpoint(endpoint); hostCache = { host: endpoint }; } catch { hostCache = { host: publicUrl }; } } // Kick off the first probe immediately so the cache is populated before any // request needs it, then keep it fresh in the background. unref() so this // timer alone doesn't keep the process (or a test run) alive. const initialProbe = refreshHostCache(); const refreshTimer = setInterval(refreshHostCache, PROBE_CACHE_MS); refreshTimer.unref?.(); async function resolvePublicHost() { await initialProbe; // no-op after the first call — already resolved return hostCache.host; } // Public client — lazily built against whichever host resolvePublicHost() // picks, so it follows the reachability check instead of a fixed endpoint. async function getPublicClient() { const endpoint = await resolvePublicHost(); return new S3Client({ endpoint, region: process.env.S3_REGION || "garage", credentials, forcePathStyle: true, }); } // ─── Key prefix map ─────────────────────────────────────────────────────────── // // ownerType mirrors what the controller already passes to chibisafe // (image, video, audio, document, avatar, thumbnail). // Maps to folder prefixes inside the bucket. // const PREFIX_MAP = { image: "images", video: "videos", audio: "audios", document: "documents", avatar: "avatars", thumbnail: "thumbnails", badge: "badges", }; function resolvePrefix(ownerType = "image") { return PREFIX_MAP[ownerType] || "others"; } function resolveExtension(originalname = "") { return path.extname(originalname).replace(".", "").toLowerCase() || "bin"; } // ─── Helpers ────────────────────────────────────────────────────────────────── // Builds the S3 object key: {prefix}/{uuid}.{ext} // e.g. images/3f2a1b4c-uuid.jpg function buildKey(originalname, ownerType) { const prefix = resolvePrefix(ownerType); const ext = resolveExtension(originalname); const uuid = crypto.randomUUID(); return `${prefix}/${uuid}.${ext}`; } // Builds the public URL for a stored object. // Garage path-style: {host}/{bucket}/{key} // e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { const host = (await resolvePublicHost()).replace(/\/$/, ""); return `${host}/${bucket}/${key}`; } // ─── uploadFile ─────────────────────────────────────────────────────────────── // // Matches chibisafe.service.js interface exactly. // input: { buffer, originalname, mimetype, ownerType? } // output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB // async function uploadFile({ buffer, originalname, mimetype, ownerType = "image" }) { if (!buffer) { throw Object.assign(new Error("File buffer is required for S3 uploads."), { status: 400 }); } const bucket = DEFAULT_BUCKET; const key = buildKey(originalname, ownerType); await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: buffer, ContentType: mimetype, })); return { url: await buildPublicUrl(key, bucket), uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage }; } // ─── deleteFile ─────────────────────────────────────────────────────────────── // // Matches chibisafe.service.js interface. // input: key — the value stored in storage_key column (e.g. "images/uuid.jpg") // async function deleteFile(key) { if (!key) return; await s3.send(new DeleteObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key, })); } // ─── getSignedDownloadUrl ───────────────────────────────────────────────────── // // Generates a short-lived pre-signed GET URL against whichever host // resolvePublicHost() picks, so the URL is resolvable from wherever the // request is served (browser or proxy server), not just the one running // Garage locally. // async function getSignedDownloadUrl(key, expiresInSeconds = 3600) { const client = await getPublicClient(); return getSignedUrl( client, new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }), { expiresIn: expiresInSeconds } ); } // ─── getObjectStream ──────────────────────────────────────────────────────── // // Fetches an S3 object and returns its readable stream + metadata, for use // in a server-side download proxy (Content-Disposition: attachment). // // input: key — the storage_key (e.g. "images/uuid.jpg") // output: { stream, contentType, contentLength } // async function getObjectStream(key) { if (!key) { throw Object.assign(new Error("Storage key is required."), { status: 400 }); } const result = await s3.send(new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key, })); return { stream: result.Body, // Node.js Readable stream contentType: result.ContentType, contentLength: result.ContentLength, }; } async function ping() { const client = await getPublicClient(); await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })); } module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };