Files
starr-philproperties/services/s3.service.js
T
kennethobsequio 095a0d4b3c add: more things
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-06 15:21:55 +08:00

244 lines
9.3 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 (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 };