Files
starr-philproperties/services/s3.service.js
T
kennethobsequio 89acdfc239 push
pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-28 11:29:07 +08:00

175 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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",
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: {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 };