107 lines
2.7 KiB
JavaScript
107 lines
2.7 KiB
JavaScript
|
|
import {
|
||
|
|
DeleteObjectCommand,
|
||
|
|
PutObjectCommand,
|
||
|
|
S3Client,
|
||
|
|
} from '@aws-sdk/client-s3';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* MinIO / S3-compatible object storage (CT 102).
|
||
|
|
*
|
||
|
|
* Env:
|
||
|
|
* S3_ENDPOINT — e.g. http://192.168.68.102:9000
|
||
|
|
* S3_ACCESS_KEY_ID — app-scoped MinIO user
|
||
|
|
* S3_SECRET_ACCESS_KEY
|
||
|
|
* S3_BUCKET — e.g. deckhearth
|
||
|
|
* S3_PUBLIC_BASE_URL — e.g. https://cdn.stillwell.cloud/deckhearth
|
||
|
|
* S3_REGION — optional, default us-east-1
|
||
|
|
*/
|
||
|
|
|
||
|
|
let client = null;
|
||
|
|
|
||
|
|
function requireEnv(name) {
|
||
|
|
const value = process.env[name];
|
||
|
|
if (!value?.trim()) {
|
||
|
|
throw new Error(`${name} is not configured`);
|
||
|
|
}
|
||
|
|
return value.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getClient() {
|
||
|
|
if (!client) {
|
||
|
|
client = new S3Client({
|
||
|
|
endpoint: requireEnv('S3_ENDPOINT'),
|
||
|
|
region: process.env.S3_REGION || 'us-east-1',
|
||
|
|
credentials: {
|
||
|
|
accessKeyId: requireEnv('S3_ACCESS_KEY_ID'),
|
||
|
|
secretAccessKey: requireEnv('S3_SECRET_ACCESS_KEY'),
|
||
|
|
},
|
||
|
|
forcePathStyle: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBucket() {
|
||
|
|
return requireEnv('S3_BUCKET');
|
||
|
|
}
|
||
|
|
|
||
|
|
function publicUrlForKey(key) {
|
||
|
|
const base = requireEnv('S3_PUBLIC_BASE_URL').replace(/\/$/, '');
|
||
|
|
return `${base}/${key.replace(/^\//, '')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Extract object key from a stored public URL or pass through raw keys. */
|
||
|
|
export function objectKeyFromReference(reference) {
|
||
|
|
if (!reference || typeof reference !== 'string') {
|
||
|
|
throw new Error('Invalid object reference');
|
||
|
|
}
|
||
|
|
if (!reference.startsWith('http')) {
|
||
|
|
return reference.replace(/^\//, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
const base = process.env.S3_PUBLIC_BASE_URL?.replace(/\/$/, '');
|
||
|
|
if (base && reference.startsWith(`${base}/`)) {
|
||
|
|
return reference.slice(base.length + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const url = new URL(reference);
|
||
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
||
|
|
const bucket = getBucket();
|
||
|
|
if (parts[0] === bucket) {
|
||
|
|
return parts.slice(1).join('/');
|
||
|
|
}
|
||
|
|
return parts.join('/');
|
||
|
|
} catch {
|
||
|
|
throw new Error('Could not parse object URL for deletion');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Drop-in replacement for `@vercel/blob` put (access ignored — bucket policy controls visibility).
|
||
|
|
* @returns {Promise<{ url: string }>}
|
||
|
|
*/
|
||
|
|
export async function put(key, body, { contentType } = {}) {
|
||
|
|
const objectKey = key.replace(/^\//, '');
|
||
|
|
await getClient().send(
|
||
|
|
new PutObjectCommand({
|
||
|
|
Bucket: getBucket(),
|
||
|
|
Key: objectKey,
|
||
|
|
Body: body,
|
||
|
|
ContentType: contentType,
|
||
|
|
})
|
||
|
|
);
|
||
|
|
return { url: publicUrlForKey(objectKey) };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Delete by object key or public URL (legacy blob URLs in DB). */
|
||
|
|
export async function del(urlOrKey) {
|
||
|
|
const objectKey = objectKeyFromReference(urlOrKey);
|
||
|
|
await getClient().send(
|
||
|
|
new DeleteObjectCommand({
|
||
|
|
Bucket: getBucket(),
|
||
|
|
Key: objectKey,
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|