mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
274 lines
12 KiB
JavaScript
274 lines
12 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 – 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,
|
||
};
|
||
|
||
// responseChecksumValidation: "WHEN_REQUIRED" — the AWS SDK v3 defaults to
|
||
// "WHEN_SUPPORTED", which validates any response carrying an x-amz-checksum-*
|
||
// header. Garage sends x-amz-checksum-crc32 on GetObject but its value doesn't
|
||
// match what the SDK recomputes over the body, so every direct GetObjectCommand
|
||
// (e.g. getObjectStream() below, used by the document-to-Markdown converter)
|
||
// threw "Checksum mismatch" even though the bytes themselves were fine.
|
||
// WHEN_REQUIRED skips validation unless the operation mandates it (GetObject
|
||
// never does) — safe here since Garage isn't computing/verifying per AWS spec
|
||
// anyway.
|
||
const CHECKSUM_CONFIG = { responseChecksumValidation: "WHEN_REQUIRED" };
|
||
|
||
// 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,
|
||
...CHECKSUM_CONFIG,
|
||
});
|
||
|
||
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,
|
||
...CHECKSUM_CONFIG,
|
||
});
|
||
}
|
||
|
||
// ─── 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 }; |