mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* 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.
329 lines
9.6 KiB
TypeScript
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;
|
|
}
|