Files
starr-philproperties/services/s3.service.js
T
kennethobsequio 0e4cd86119 s3 service adjustment
quickly

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

219 lines
9.1 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).
//
// This used to probe S3_ENDPOINT from the server and prefer it when reachable,
// but that measures the wrong machine: Garage is always co-located with this
// backend (see docker-compose.yml), so the probe was *always* reachable from
// here and always resolved to 127.0.0.1 — even for browsers on other machines,
// which then failed to connect to it. There is no way for the server to
// determine what's reachable from the client by probing itself, so just trust
// config: prefer S3_PUBLIC_URL whenever it's set, and only fall back to
// S3_ENDPOINT for pure single-machine dev setups with no public URL at all.
function resolvePublicHost() {
return process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "";
}
// Public client — built against whichever host resolvePublicHost() picks.
function getPublicClient() {
const endpoint = 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 S3_ENDPOINT (internal —
// this backend is always co-located with Garage, see docker-compose.yml).
// For server-side reads only (e.g. media.controller.js streamAsset piping
// bytes to the browser itself) — never hand this URL to a browser directly.
//
// Must NOT be signed against S3_PUBLIC_URL: that host is fronted by
// garage-anon-proxy, which re-signs every request itself (header-based SigV4,
// real credentials) regardless of any query-string signature already present.
// A presigned URL arriving there collides with the proxy's own signature and
// Garage rejects the request (400 "Header `x-amz-date` should be signed").
// See getPublicUrl() below for the browser-facing equivalent.
//
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
return getSignedUrl(
s3,
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
{ expiresIn: expiresInSeconds }
);
}
// ─── getPublicUrl ─────────────────────────────────────────────────────────────
//
// Browser-facing object URL, unsigned. garage-anon-proxy (fronting
// S3_PUBLIC_URL) authenticates every request server-side with real
// credentials, so a client-side presign adds nothing but a broken request —
// see getSignedDownloadUrl() above. Use this wherever a URL is handed
// directly to the browser (e.g. thumbnail previews).
//
async function getPublicUrl(key, bucket = DEFAULT_BUCKET) {
return buildPublicUrl(key, bucket);
}
// ─── 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, getPublicUrl, getObjectStream, ping };