add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-06 15:21:55 +08:00
parent 7ba03ad4db
commit 095a0d4b3c
15 changed files with 756 additions and 211 deletions
+92 -23
View File
@@ -6,12 +6,14 @@
// deleteFile(key)
//
// Required .env vars:
// S3_ENDPOINT – http://127.0.0.1:3900
// 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
// 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");
@@ -26,6 +28,8 @@ const credentials = {
};
// 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",
@@ -33,19 +37,80 @@ const s3 = new S3Client({
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;
const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, "");
// ─── 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 ───────────────────────────────────────────────────────────
//
@@ -83,10 +148,11 @@ function buildKey(originalname, ownerType) {
}
// Builds the public URL for a stored object.
// Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key}
// Garage path-style: {host}/{bucket}/{key}
// e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg
function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
return `${PUBLIC_URL}/${bucket}/${key}`;
async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
const host = (await resolvePublicHost()).replace(/\/$/, "");
return `${host}/${bucket}/${key}`;
}
// ─── uploadFile ───────────────────────────────────────────────────────────────
@@ -111,7 +177,7 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image"
}));
return {
url: buildPublicUrl(key, bucket),
url: await buildPublicUrl(key, bucket),
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
};
}
@@ -131,13 +197,15 @@ async function deleteFile(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.
// 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(
s3Public,
client,
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
{ expiresIn: expiresInSeconds }
);
@@ -169,7 +237,8 @@ async function getObjectStream(key) {
}
async function ping() {
await s3Public.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
const client = await getPublicClient();
await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
}
module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };