Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
133 lines
5.7 KiB
JavaScript
133 lines
5.7 KiB
JavaScript
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
import { Upload } from '@aws-sdk/lib-storage';
|
|
import pool from '../db/pool.js';
|
|
|
|
// ── Mutable config ────────────────────────────────────────────────────────────
|
|
let _cfg = {
|
|
endpoint: process.env.S3_ENDPOINT || '',
|
|
bucket: process.env.S3_BUCKET || 'mam',
|
|
accessKey: process.env.S3_ACCESS_KEY || '',
|
|
secretKey: process.env.S3_SECRET_KEY || '',
|
|
region: process.env.S3_REGION || 'us-east-1',
|
|
};
|
|
|
|
// ── Client factory ────────────────────────────────────────────────────────────
|
|
function buildClient(cfg) {
|
|
return new S3Client({
|
|
region: cfg.region,
|
|
endpoint: cfg.endpoint || undefined,
|
|
credentials: {
|
|
accessKeyId: cfg.accessKey,
|
|
secretAccessKey: cfg.secretKey,
|
|
},
|
|
forcePathStyle: true,
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
});
|
|
}
|
|
|
|
let _client = buildClient(_cfg);
|
|
|
|
// ── Proxy export — always delegates to the live _client ───────────────────────
|
|
// Existing code that does `s3Client.send(cmd)` continues to work even after
|
|
// rebuildS3Client() replaces the underlying instance.
|
|
export const s3Client = new Proxy(
|
|
{},
|
|
{
|
|
get(_target, prop) {
|
|
// Bug #61: bind method calls to the current _client so `this` is correct
|
|
// even after rebuildS3Client() replaces the underlying instance.
|
|
const val = Reflect.get(_client, prop, _client);
|
|
return typeof val === 'function' ? val.bind(_client) : val;
|
|
},
|
|
}
|
|
);
|
|
|
|
// ── Bucket getter — always returns the current bucket name ───────────────────
|
|
export function getS3Bucket() { return _cfg.bucket; }
|
|
|
|
// @deprecated — import binding captures the value at module load time and
|
|
// will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead.
|
|
export let S3_BUCKET = _cfg.bucket;
|
|
|
|
// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
|
|
export function rebuildS3Client(overrides = {}) {
|
|
_cfg = { ..._cfg, ...overrides };
|
|
S3_BUCKET = _cfg.bucket;
|
|
_client = buildClient(_cfg);
|
|
console.log('[s3] client rebuilt — endpoint:', _cfg.endpoint || '(AWS)', ' bucket:', _cfg.bucket);
|
|
}
|
|
|
|
// ── Load config from DB on startup ───────────────────────────────────────────
|
|
// Called in index.js after migrations. DB values override env-var defaults.
|
|
export async function loadS3ConfigFromDb() {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT key, value FROM settings
|
|
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
|
|
AND value IS NOT NULL AND value <> ''`
|
|
);
|
|
if (result.rows.length === 0) return;
|
|
const overrides = {};
|
|
for (const { key, value } of result.rows) {
|
|
switch (key) {
|
|
case 's3_endpoint': overrides.endpoint = value; break;
|
|
case 's3_bucket': overrides.bucket = value; break;
|
|
case 's3_access_key': overrides.accessKey = value; break;
|
|
case 's3_secret_key': overrides.secretKey = value; break;
|
|
case 's3_region': overrides.region = value; break;
|
|
}
|
|
}
|
|
rebuildS3Client(overrides);
|
|
console.log('[s3] config loaded from DB settings');
|
|
} catch (err) {
|
|
console.error('[s3] failed to load config from DB:', err.message);
|
|
}
|
|
}
|
|
|
|
// ── One-shot test client (does not replace _client) ──────────────────────────
|
|
export function buildTestClient(cfg) {
|
|
return buildClient({
|
|
endpoint: cfg.endpoint ?? _cfg.endpoint,
|
|
bucket: cfg.bucket ?? _cfg.bucket,
|
|
accessKey: cfg.accessKey ?? _cfg.accessKey,
|
|
secretKey: cfg.secretKey ?? _cfg.secretKey,
|
|
region: cfg.region ?? _cfg.region,
|
|
});
|
|
}
|
|
|
|
// ── Connectivity test (used by /settings/s3/test) ────────────────────────────
|
|
export async function testS3Connection(client, bucket) {
|
|
try {
|
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
return { ok: true, message: `Bucket "${bucket}" is accessible` };
|
|
} catch (headErr) {
|
|
// Some S3-compatible stores don't implement HeadBucket — try a list
|
|
try {
|
|
await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 }));
|
|
return { ok: true, message: `Bucket "${bucket}" is accessible` };
|
|
} catch (listErr) {
|
|
throw new Error(listErr.message || headErr.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Utility helpers (all reference the live _client / _cfg) ──────────────────
|
|
export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => {
|
|
const params = { Bucket: _cfg.bucket, Key: key };
|
|
if (contentType) params.ResponseContentType = contentType;
|
|
return getSignedUrl(_client, new GetObjectCommand(params), { expiresIn });
|
|
};
|
|
|
|
export const uploadStream = async (key, stream, contentType) => {
|
|
const upload = new Upload({
|
|
client: _client,
|
|
params: { Bucket: _cfg.bucket, Key: key, Body: stream, ContentType: contentType },
|
|
});
|
|
return upload.done();
|
|
};
|
|
|
|
export const deleteObject = async (key) => {
|
|
return _client.send(new DeleteObjectCommand({ Bucket: _cfg.bucket, Key: key }));
|
|
};
|