Files
starr-philproperties/services/s3.service.js
T
kennethobsequio f5254f7571 test again
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-13 12:46:38 +08:00

261 lines
11 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 – address this backend uses for uploads/deletes.
// http://127.0.0.1:3900 when co-located with Garage.
// Otherwise (backend runs on a different machine than
// Garage) point this at a network-reachable address that
// terminates on Garage — e.g. the same tunneled domain as
// S3_PUBLIC_URL, since garage-anon-proxy re-signs any
// non-presigned request with real credentials before
// forwarding. Never leave this blank.
// 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, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
const { Upload } = require("@aws-sdk/lib-storage");
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. When this backend is co-located with Garage,
// that's a local address; when it isn't, S3_ENDPOINT must instead be a
// network-reachable address that reaches Garage (e.g. the tunneled proxy
// domain) — see the .env docs above. Do not assume co-location here.
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
//
// onProgress?: ({ loaded, total }) => void — real bytes-sent-to-Garage events,
// straight from the AWS SDK's own httpUploadProgress, not simulated. A plain
// PutObjectCommand (what this used to be) has no progress API at all; Upload
// auto-splits into multipart above its ~5MB partSize threshold, so large
// files (the case this actually matters for) report genuine incremental
// progress per part, while small ones just jump from 0 to 100 immediately.
async function uploadFile({ buffer, originalname, mimetype, ownerType = "image", onProgress }) {
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);
const uploader = new Upload({
client: s3,
params: { Bucket: bucket, Key: key, Body: buffer, ContentType: mimetype },
});
if (onProgress) {
uploader.on("httpUploadProgress", (progress) => {
onProgress({ loaded: progress.loaded ?? 0, total: progress.total ?? buffer.length });
});
}
await uploader.done();
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 when Garage is in use: 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").
//
// Uses the internal `s3` client (always S3_ENDPOINT) when available; falls back
// to getPublicClient() for external S3-compatible services without Garage.
//
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
const client = process.env.S3_ENDPOINT ? s3 : getPublicClient();
return getSignedUrl(
client,
new GetObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}),
{
expiresIn: expiresInSeconds,
}
);
}
// ─── getPublicUrl ─────────────────────────────────────────────────────────────
//
// Browser-facing pre-signed GET URL, signed against S3_PUBLIC_URL. Safe again
// now that garage-anon-proxy detects a valid query-string SigV4 signature and
// forwards it unmodified instead of re-signing on top of it (see
// isPresignedRequest() in garage-anon-proxy/server.js) — that mismatch used to
// produce Garage's 400 "Header `x-amz-date` should be signed". Use this
// wherever a URL is handed directly to the browser (e.g. thumbnail previews).
//
// expiresInSeconds defaults to 4h to outlive the longest cache window a caller
// hands this URL out under (client media token TTL — see media.controller.js),
// so a cached thumbnail_url never outlives its own signature.
//
async function getPublicUrl(key, bucket = DEFAULT_BUCKET, expiresInSeconds = 4 * 60 * 60) {
const client = getPublicClient();
return getSignedUrl(
client,
new GetObjectCommand({ Bucket: 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, getPublicUrl, getObjectStream, ping };