// 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 // S3_REGION – garage // S3_ACCESS_KEY // S3_SECRET_KEY // S3_BUCKET – philproperties // S3_PUBLIC_URL – https://garage.philproperties.com 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. const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || "garage", credentials, forcePathStyle: true, }); // Public client — generates pre-signed URLs using the externally reachable // endpoint (S3_PUBLIC_URL) so URLs work from any machine, not just the one // running Garage. Falls back to the internal endpoint when S3_PUBLIC_URL is // unset (single-machine dev). const s3Public = new S3Client({ endpoint: process.env.S3_PUBLIC_URL ?? process.env.S3_ENDPOINT, region: process.env.S3_REGION || "garage", credentials, forcePathStyle: true, }); const DEFAULT_BUCKET = process.env.S3_BUCKET || "philproperties"; const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, ""); // ─── 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", }; 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: {S3_PUBLIC_URL}/{bucket}/{key} // e.g. https://garage.philproperties.com/philproperties/images/uuid.jpg function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { return `${PUBLIC_URL}/${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: 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 using the public endpoint so the // URL is resolvable from any machine (browser or proxy server), not just the // one running Garage locally. // async function getSignedDownloadUrl(key, expiresInSeconds = 3600) { return getSignedUrl( s3Public, 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() { await s3Public.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })); } module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };