Files
SnapOtter/packages/enterprise/src/storage-s3.ts
T
SnapOtterandGitHub 8f4235d2c6 fix(enterprise): ship enterprise package in prod image + S3, analytics, tracing, queue fixes (#342)
* fix(enterprise): ship enterprise pkg in prod image, full license features, tracing key fallback

docker/Dockerfile: COPY packages/enterprise manifest+src into the production stage.
Without it, apps/api's workspace link to @snapotter/enterprise dangles and every
import() throws (silently caught), so all 19 enterprise features failed closed
(enterprise.active=false) regardless of a valid license.

scripts/generate-license.mjs: sync PLAN_FEATURES with packages/enterprise/src/license.ts
so a --plan enterprise license unlocks all 19 features (was 8) and team unlocks 8.

apps/api/src/tracing.ts: accept SNAPOTTER_LICENSE_KEY as a fallback to LICENSE_KEY so
distributed_tracing activates with the same key as the rest of the app.

* fix(docker): keep scripts/bake-analytics.mjs in build context

.dockerignore excluded the whole scripts/ dir (PR #82, V1 hardening), but
docker/Dockerfile later added 'COPY scripts/bake-analytics.mjs' for the analytics
bake step. A clean production image build therefore fails with
'scripts/bake-analytics.mjs: not found'. The published image build is gated off in
CI so this latent break went unnoticed. Exclude scripts/* but re-include the one
file the Dockerfile needs.

* fix: S3 upload stream, analytics bake reaches API, dedupe retention field, reconcile orphan jobs

storage-s3.ts: wrap the upload AsyncIterable in Readable.from() so @aws-sdk/lib-storage
accepts it. STORAGE_MODE=s3 file uploads failed with 'Body Data is unsupported format'
for every tool because a bare async generator is not a Readable.

docker/Dockerfile: COPY the builder-baked analytics baked.ts into the API runtime stage.
The API re-copied the committed (off) baked.ts from the build context, so the
SNAPOTTER_ANALYTICS build arg had no effect on the API -- and since the SPA reads
/api/v1/config/analytics, analytics was off everywhere regardless of the arg.

settings-dialog.tsx: remove the duplicate tempFileMaxAgeHours control under Data
Retention; it bound the same setting key as the File Management control with a different
default, so editing either silently overwrote the other.

apps/api/src/index.ts: reconcile orphaned job rows (empty tool_id, never enqueued to
BullMQ) at boot so they don't sit in processing/queued forever and inflate the per-user
concurrent-job count and the upgrade-check in-flight gate.

* fix(web): style the SSO login buttons (they referenced undefined theme tokens)

The OIDC/SAML 'Sign in with <provider>' buttons used bg-secondary /
text-secondary-foreground, which the web theme never defines (it has primary,
background, foreground, muted, border, card, primary-subtle). Those classes resolved
to nothing, so the buttons rendered as bare unstyled text on the login page.

Restyle: the optional (non-enforced) buttons become white-card outline buttons with a
key icon and an orange hover tint, secondary to the primary Login button; the
SSO-enforced buttons become solid primary with the icon.

* fix: gate S3 behind license, custom-role enterprise perms, wire retention UI, cleanup

S3 is a licensed feature, but shipping packages/enterprise in every image removed the
implicit gate, so STORAGE_MODE=s3 worked without a license. Enforce
isFeatureEnabled('s3_storage') at boot and fail fast if unlicensed.

Custom roles can now be granted security:manage / compliance:manage / webhooks:manage
(roles.ts ALL_PERMISSIONS + the Roles UI) so admins can build least-privilege
compliance/security roles instead of only the built-in admin role.

retentionSweep now reads the jobsRetentionDays / auditRetentionDays DB settings the
System Settings UI writes (env vars become the fallback default), mirroring how the
temp-file sweep reads tempFileMaxAgeHours. Previously those two UI controls were no-ops.

Cleanup: drop the never-set snapotter_storage_bytes gauge and the unused
MAX_WORKSPACE_SIZE_GB env var; emit tool_client_error to PostHog from the web
ErrorBoundary (client crashes were not reaching analytics); add the Python
OpenTelemetry packages so the innermost sidecar.<script> span exports; fix the stale
'only local storage' line in the docs; delete two e2e-analytics specs that tested the
removed consent UI.

* fix(env): restore MAX_WORKSPACE_SIZE_GB default

security-auth-hardening.test.ts asserts env.MAX_WORKSPACE_SIZE_GB defaults to 10, so
the var is an intentional (tested) default, not dead code. Removing it in the cleanup
commit broke that unit test. Keep the declaration.
2026-06-24 17:27:59 +08:00

329 lines
9.6 KiB
TypeScript

import { Readable } from "node:stream";
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
export interface S3Config {
bucket: string;
region: string;
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean;
prefix: string;
}
let config: S3Config | null = null;
let client: S3Client | null = null;
export function configureS3(opts: S3Config): void {
config = opts;
client = null; // Reset so next getClient() picks up new config
}
function cfg(): S3Config {
if (!config) {
throw new Error("S3 storage not configured. Call configureS3() first.");
}
return config;
}
function getClient(): S3Client {
if (!client) {
const c = cfg();
client = new S3Client({
region: c.region,
endpoint: c.endpoint || undefined,
forcePathStyle: c.forcePathStyle,
credentials: {
accessKeyId: c.accessKeyId,
secretAccessKey: c.secretAccessKey,
},
});
}
return client;
}
function fileKey(storedName: string): string {
const prefix = cfg().prefix ? `${cfg().prefix}/` : "";
return `${prefix}files/${storedName}`;
}
function thumbKey(storedName: string): string {
const prefix = cfg().prefix ? `${cfg().prefix}/` : "";
return `${prefix}thumbs/${storedName}.thumb.jpg`;
}
export async function checkConnection(): Promise<void> {
await getClient().send(new HeadBucketCommand({ Bucket: cfg().bucket }));
}
export async function putObject(storedName: string, buffer: Buffer): Promise<void> {
await getClient().send(
new PutObjectCommand({
Bucket: cfg().bucket,
Key: fileKey(storedName),
Body: buffer,
}),
);
}
export async function getObject(storedName: string): Promise<Buffer> {
const response = await getClient().send(
new GetObjectCommand({
Bucket: cfg().bucket,
Key: fileKey(storedName),
}),
);
return Buffer.from(await response.Body!.transformToByteArray());
}
export async function getObjectStream(storedName: string): Promise<Readable> {
const response = await getClient().send(
new GetObjectCommand({
Bucket: cfg().bucket,
Key: fileKey(storedName),
}),
);
return response.Body as Readable;
}
export async function deleteObject(storedName: string): Promise<void> {
try {
await getClient().send(
new DeleteObjectCommand({
Bucket: cfg().bucket,
Key: fileKey(storedName),
}),
);
} catch {
// Object already gone or doesn't exist
}
}
export async function getThumbnail(storedName: string): Promise<Buffer | null> {
try {
const response = await getClient().send(
new GetObjectCommand({
Bucket: cfg().bucket,
Key: thumbKey(storedName),
}),
);
return Buffer.from(await response.Body!.transformToByteArray());
} catch {
return null;
}
}
export async function putThumbnail(storedName: string, buffer: Buffer): Promise<void> {
await getClient().send(
new PutObjectCommand({
Bucket: cfg().bucket,
Key: thumbKey(storedName),
Body: buffer,
ContentType: "image/jpeg",
}),
);
}
export async function deleteThumbnail(storedName: string): Promise<void> {
try {
await getClient().send(
new DeleteObjectCommand({
Bucket: cfg().bucket,
Key: thumbKey(storedName),
}),
);
} catch {
// Thumbnail may not exist
}
}
// ---------------------------------------------------------------------------
// Generic object operations for uploads/ and outputs/ processing artifacts.
// Called by apps/api/src/lib/object-storage.ts when STORAGE_MODE=s3.
// Keys are passed verbatim (e.g. "outputs/<jobId>/result.png") and joined
// with the configured S3_PREFIX, consistent with fileKey/thumbKey above.
// ---------------------------------------------------------------------------
export interface GenericObjectInfo {
key: string;
size: number;
mtimeMs: number;
}
function genericKey(key: string): string {
const prefix = cfg().prefix ? `${cfg().prefix}/` : "";
return `${prefix}${key}`;
}
export async function putGenericObject(key: string, data: Buffer): Promise<void> {
await getClient().send(
new PutObjectCommand({
Bucket: cfg().bucket,
Key: genericKey(key),
Body: data,
}),
);
}
export async function putGenericObjectStream(
key: string,
source: AsyncIterable<Buffer>,
): Promise<void> {
const upload = new Upload({
client: getClient(),
params: {
Bucket: cfg().bucket,
Key: genericKey(key),
// @aws-sdk/lib-storage Upload only accepts string|Uint8Array|Buffer|Readable|
// ReadableStream|Blob. A bare AsyncIterable (the counter() generator from
// object-storage.putObjectStream) is none of those, so S3 uploads failed with
// "Body Data is unsupported format". Wrap it in a real Node Readable.
Body: Readable.from(source),
},
});
await upload.done();
}
export async function getGenericObjectStream(
key: string,
range?: { start: number; end?: number },
): Promise<Readable> {
const params: { Bucket: string; Key: string; Range?: string } = {
Bucket: cfg().bucket,
Key: genericKey(key),
};
if (range) {
params.Range = `bytes=${range.start}-${range.end ?? ""}`;
}
const response = await getClient().send(new GetObjectCommand(params));
return response.Body as Readable;
}
export async function getGenericObjectSize(key: string): Promise<number> {
const response = await getClient().send(
new HeadObjectCommand({
Bucket: cfg().bucket,
Key: genericKey(key),
}),
);
return response.ContentLength ?? 0;
}
export async function deleteGenericObject(key: string): Promise<void> {
try {
await getClient().send(
new DeleteObjectCommand({
Bucket: cfg().bucket,
Key: genericKey(key),
}),
);
} catch {
// Object already gone or doesn't exist
}
}
export async function deleteGenericPrefix(prefix: string): Promise<void> {
const fullPrefix = genericKey(prefix.endsWith("/") ? prefix : `${prefix}/`);
let continuationToken: string | undefined;
do {
const list = await getClient().send(
new ListObjectsV2Command({
Bucket: cfg().bucket,
Prefix: fullPrefix,
ContinuationToken: continuationToken,
}),
);
const keys = (list.Contents ?? []).map((o) => o.Key).filter((k): k is string => !!k);
if (keys.length > 0) {
// DeleteObjects supports up to 1000 keys per call
for (let i = 0; i < keys.length; i += 1000) {
const batch = keys.slice(i, i + 1000);
const deleteResult = await getClient().send(
new DeleteObjectsCommand({
Bucket: cfg().bucket,
Delete: { Objects: batch.map((Key) => ({ Key })) },
}),
);
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
const summary = deleteResult.Errors.map((e) => `${e.Key}: ${e.Code}`).join(", ");
throw new Error(`S3 DeleteObjects partial failure: ${summary}`);
}
}
}
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
} while (continuationToken);
}
export async function listGenericObjects(prefix: string): Promise<GenericObjectInfo[]> {
const fullPrefix = genericKey(prefix.endsWith("/") ? prefix : `${prefix}/`);
const s3Prefix = cfg().prefix ? `${cfg().prefix}/` : "";
const out: GenericObjectInfo[] = [];
let continuationToken: string | undefined;
do {
const list = await getClient().send(
new ListObjectsV2Command({
Bucket: cfg().bucket,
Prefix: fullPrefix,
ContinuationToken: continuationToken,
}),
);
for (const obj of list.Contents ?? []) {
if (!obj.Key) continue;
// Strip the S3_PREFIX to return keys in the caller's namespace
const key =
s3Prefix && obj.Key.startsWith(s3Prefix) ? obj.Key.slice(s3Prefix.length) : obj.Key;
out.push({
key,
size: obj.Size ?? 0,
mtimeMs: obj.LastModified ? obj.LastModified.getTime() : 0,
});
}
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
} while (continuationToken);
return out;
}
// Lists the top-level "job directories" under a prefix (uploads/ or outputs/).
// Uses ListObjectsV2 with Delimiter="/" to get CommonPrefixes. S3 does not
// store directory mtime, so we set mtimeMs=0. The TTL sweeper should instead
// rely on the jobs table's updatedAt column for expiry decisions; this listing
// only provides the directory keys for matching against job records.
export async function listGenericJobDirs(
prefix: "uploads" | "outputs",
): Promise<GenericObjectInfo[]> {
const fullPrefix = genericKey(`${prefix}/`);
const s3Prefix = cfg().prefix ? `${cfg().prefix}/` : "";
const out: GenericObjectInfo[] = [];
let continuationToken: string | undefined;
do {
const list = await getClient().send(
new ListObjectsV2Command({
Bucket: cfg().bucket,
Prefix: fullPrefix,
Delimiter: "/",
ContinuationToken: continuationToken,
}),
);
for (const cp of list.CommonPrefixes ?? []) {
if (!cp.Prefix) continue;
// Strip S3_PREFIX and trailing slash to normalize: "outputs/jobId"
let key =
s3Prefix && cp.Prefix.startsWith(s3Prefix) ? cp.Prefix.slice(s3Prefix.length) : cp.Prefix;
key = key.replace(/\/$/, "");
out.push({ key, size: 0, mtimeMs: 0 });
}
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
} while (continuationToken);
return out;
}