mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
244 lines
9.3 KiB
JavaScript
244 lines
9.3 KiB
JavaScript
// 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 }; |