feat: add enterprise licensing and S3 storage backend

Add the enterprise package with Ed25519 license key validation and
feature gating. Enterprise code lives in the public repo under a
proprietary license (Cal.com/PostHog model), protected legally, not
by code hiding.

Implement S3-compatible storage backend as the first enterprise
feature. The file-storage module now delegates to either local
filesystem or S3 based on STORAGE_MODE env var. Works with AWS S3,
Cloudflare R2, DigitalOcean Spaces, MinIO, and any S3-compatible
provider. Workspace files remain local (ephemeral processing).

New env vars: STORAGE_MODE, S3_BUCKET, S3_REGION, S3_ENDPOINT,
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE,
S3_PREFIX, SNAPOTTER_LICENSE_KEY.

Tested against MinIO: 10 S3 integration tests + 82 existing tests
pass with zero regressions.
This commit is contained in:
SnapOtter
2026-06-06 20:17:49 +08:00
parent 06d1822491
commit 3b84fab765
15 changed files with 658 additions and 27 deletions
+21
View File
@@ -98,6 +98,20 @@ if (!env.COOKIE_SECRET) {
await initAnalytics();
// Enterprise features (license-gated)
let enterpriseLicense: { org: string; plan: string } | null = null;
try {
const { initEnterprise } = await import("@snapotter/enterprise");
const result = initEnterprise(env.SNAPOTTER_LICENSE_KEY || undefined);
if (result.valid && result.license) {
enterpriseLicense = result.license;
} else if (env.SNAPOTTER_LICENSE_KEY) {
console.warn("[WARN] Invalid or expired enterprise license key");
}
} catch {
// Enterprise package not available
}
// Mark any jobs left in processing/queued from a previous unclean shutdown
recoverStaleJobs();
@@ -278,6 +292,9 @@ app.get("/api/v1/admin/health", async (request, reply) => {
database: dbOk ? "ok" : "error",
queue: { active: 0, pending: 0 },
ai: { gpu: isGpuAvailable(), dispatcher: getDispatcherStatus() },
enterprise: enterpriseLicense
? { active: true, org: enterpriseLicense.org, plan: enterpriseLicense.plan }
: { active: false },
};
});
@@ -319,6 +336,10 @@ try {
`[INFO] Rate limit: ${env.RATE_LIMIT_PER_MIN > 0 ? `${env.RATE_LIMIT_PER_MIN}/min` : "disabled"}`,
`[INFO] Upload limit: ${env.MAX_UPLOAD_SIZE_MB > 0 ? `${env.MAX_UPLOAD_SIZE_MB} MB` : "unlimited"}`,
`[INFO] Trust proxy: ${env.TRUST_PROXY}`,
`[INFO] Storage: ${env.STORAGE_MODE}${env.STORAGE_MODE === "s3" ? ` (${env.S3_BUCKET})` : ""}`,
enterpriseLicense
? `[INFO] Enterprise license: ${enterpriseLicense.org} (${enterpriseLicense.plan})`
: "[INFO] Edition: Community",
].join("\n"),
);
} catch (err) {
+34
View File
@@ -15,6 +15,17 @@ const envSchema = z
.default("false")
.transform((v) => v === "true"),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
S3_BUCKET: z.string().default(""),
S3_REGION: z.string().default("us-east-1"),
S3_ENDPOINT: z.string().default(""),
S3_ACCESS_KEY_ID: z.string().default(""),
S3_SECRET_ACCESS_KEY: z.string().default(""),
S3_FORCE_PATH_STYLE: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
S3_PREFIX: z.string().default(""),
SNAPOTTER_LICENSE_KEY: z.string().default(""),
FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0),
@@ -82,6 +93,29 @@ const envSchema = z
),
})
.superRefine((data, ctx) => {
if (data.STORAGE_MODE === "s3") {
if (!data.S3_BUCKET) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "S3_BUCKET is required when STORAGE_MODE=s3",
path: ["S3_BUCKET"],
});
}
if (!data.S3_ACCESS_KEY_ID) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "S3_ACCESS_KEY_ID is required when STORAGE_MODE=s3",
path: ["S3_ACCESS_KEY_ID"],
});
}
if (!data.S3_SECRET_ACCESS_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "S3_SECRET_ACCESS_KEY is required when STORAGE_MODE=s3",
path: ["S3_SECRET_ACCESS_KEY"],
});
}
}
if (data.OIDC_ENABLED) {
if (!data.OIDC_ISSUER_URL) {
ctx.addIssue({
+77 -13
View File
@@ -1,14 +1,12 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, readFile, statfs, unlink, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import type { Readable } from "node:stream";
import { env } from "../config.js";
/** Minimum free disk space (100 MB) before refusing writes. */
const MIN_FREE_BYTES = 100 * 1024 * 1024;
/**
* Check available disk space and throw 507 if below threshold.
*/
async function assertDiskSpace(dir: string): Promise<void> {
try {
const stats = await statfs(dir);
@@ -19,7 +17,6 @@ async function assertDiskSpace(dir: string): Promise<void> {
throw err;
}
} catch (e) {
// Re-throw our own 507 errors; swallow statfs failures (e.g. unsupported OS)
if (e instanceof Error && (e as Error & { statusCode?: number }).statusCode === 507) throw e;
}
}
@@ -52,10 +49,39 @@ const SAFE_STORAGE_EXTENSIONS = new Set([
".hdr",
]);
// ── S3 backend (lazy-loaded only when STORAGE_MODE=s3) ──────────────
let s3Mod: typeof import("./storage-s3.js") | null = null;
async function s3(): Promise<typeof import("./storage-s3.js")> {
if (!s3Mod) s3Mod = await import("./storage-s3.js");
return s3Mod;
}
function useS3(): boolean {
return env.STORAGE_MODE === "s3";
}
// ── Filename generation ─────────────────────────────────────────────
function generateStoredName(originalName: string): string {
let ext = extname(originalName).toLowerCase() || ".bin";
if (!SAFE_STORAGE_EXTENSIONS.has(ext)) ext = ".bin";
return `${randomUUID()}${ext}`;
}
// ── Public API ──────────────────────────────────────────────────────
let storageReady = false;
export async function ensureStorageDir(): Promise<void> {
if (storageReady) return;
if (useS3()) {
const mod = await s3();
await mod.checkConnection();
storageReady = true;
return;
}
try {
await mkdir(env.FILES_STORAGE_PATH, { recursive: true });
} catch (e) {
@@ -72,15 +98,14 @@ export async function ensureStorageDir(): Promise<void> {
}
export async function saveFile(buffer: Buffer, originalName: string): Promise<string> {
const storedName = generateStoredName(originalName);
if (useS3()) {
const mod = await s3();
await mod.putObject(storedName, buffer);
return storedName;
}
await ensureStorageDir();
await assertDiskSpace(env.FILES_STORAGE_PATH);
let ext = extname(originalName).toLowerCase() || ".bin";
// Only allow known image extensions to be stored — reject dangerous extensions
// even if they somehow pass upstream sanitization.
if (!SAFE_STORAGE_EXTENSIONS.has(ext)) {
ext = ".bin";
}
const storedName = `${randomUUID()}${ext}`;
try {
await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer);
} catch (e) {
@@ -96,7 +121,28 @@ export async function saveFile(buffer: Buffer, originalName: string): Promise<st
return storedName;
}
export async function readStoredFile(storedName: string): Promise<Buffer> {
if (useS3()) {
const mod = await s3();
return mod.getObject(storedName);
}
return readFile(join(env.FILES_STORAGE_PATH, storedName));
}
export async function streamStoredFile(storedName: string): Promise<Readable> {
if (useS3()) {
const mod = await s3();
return mod.getObjectStream(storedName);
}
return createReadStream(join(env.FILES_STORAGE_PATH, storedName));
}
export async function deleteStoredFile(storedName: string): Promise<void> {
if (useS3()) {
const mod = await s3();
await mod.deleteObject(storedName);
return;
}
try {
await unlink(join(env.FILES_STORAGE_PATH, storedName));
} catch {
@@ -108,13 +154,17 @@ export function getStoredFilePath(storedName: string): string {
return join(env.FILES_STORAGE_PATH, storedName);
}
// ── Thumbnail cache ─────────────────────────────────────────────────
// ── Thumbnail cache ─────────────────────────────────────────────────
const THUMB_DIR = ".thumbs";
let thumbDirReady = false;
async function ensureThumbDir(): Promise<void> {
if (thumbDirReady) return;
if (useS3()) {
thumbDirReady = true;
return;
}
try {
await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true });
} catch (err: unknown) {
@@ -131,6 +181,10 @@ function thumbPath(storedName: string): string {
}
export async function getCachedThumbnail(storedName: string): Promise<Buffer | null> {
if (useS3()) {
const mod = await s3();
return mod.getThumbnail(storedName);
}
try {
return await readFile(thumbPath(storedName));
} catch {
@@ -139,11 +193,21 @@ export async function getCachedThumbnail(storedName: string): Promise<Buffer | n
}
export async function saveThumbnail(storedName: string, buffer: Buffer): Promise<void> {
if (useS3()) {
const mod = await s3();
await mod.putThumbnail(storedName, buffer);
return;
}
await ensureThumbDir();
await writeFile(thumbPath(storedName), buffer);
}
export async function deleteThumbnail(storedName: string): Promise<void> {
if (useS3()) {
const mod = await s3();
await mod.deleteThumbnail(storedName);
return;
}
try {
await unlink(thumbPath(storedName));
} catch {
+121
View File
@@ -0,0 +1,121 @@
import type { Readable } from "node:stream";
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { env } from "../config.js";
let client: S3Client | null = null;
function getClient(): S3Client {
if (!client) {
client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT || undefined,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
}
return client;
}
function fileKey(storedName: string): string {
const prefix = env.S3_PREFIX ? `${env.S3_PREFIX}/` : "";
return `${prefix}files/${storedName}`;
}
function thumbKey(storedName: string): string {
const prefix = env.S3_PREFIX ? `${env.S3_PREFIX}/` : "";
return `${prefix}thumbs/${storedName}.thumb.jpg`;
}
export async function checkConnection(): Promise<void> {
await getClient().send(new HeadBucketCommand({ Bucket: env.S3_BUCKET }));
}
export async function putObject(storedName: string, buffer: Buffer): Promise<void> {
await getClient().send(
new PutObjectCommand({
Bucket: env.S3_BUCKET,
Key: fileKey(storedName),
Body: buffer,
}),
);
}
export async function getObject(storedName: string): Promise<Buffer> {
const response = await getClient().send(
new GetObjectCommand({
Bucket: env.S3_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: env.S3_BUCKET,
Key: fileKey(storedName),
}),
);
return response.Body as Readable;
}
export async function deleteObject(storedName: string): Promise<void> {
try {
await getClient().send(
new DeleteObjectCommand({
Bucket: env.S3_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: env.S3_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: env.S3_BUCKET,
Key: thumbKey(storedName),
Body: buffer,
ContentType: "image/jpeg",
}),
);
}
export async function deleteThumbnail(storedName: string): Promise<void> {
try {
await getClient().send(
new DeleteObjectCommand({
Bucket: env.S3_BUCKET,
Key: thumbKey(storedName),
}),
);
} catch {
// Thumbnail may not exist
}
}
+9 -14
View File
@@ -10,8 +10,6 @@
* POST /api/v1/files/save-result Save a tool processing result (new version)
*/
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -24,9 +22,10 @@ import {
deleteStoredFile,
deleteThumbnail,
getCachedThumbnail,
getStoredFilePath,
readStoredFile,
saveFile,
saveThumbnail,
streamStoredFile,
} from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
@@ -375,14 +374,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(404).send({ error: "File not found" });
}
const filePath = getStoredFilePath(file.storedName);
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!reply.raw.headersSent) {
reply.status(404).send({ error: "File not found on disk" });
}
});
let stream;
try {
stream = await streamStoredFile(file.storedName);
} catch {
return reply.status(404).send({ error: "File not found in storage" });
}
return reply
.header("Content-Type", file.mimeType)
@@ -422,10 +419,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
.send(cached);
}
const filePath = getStoredFilePath(file.storedName);
try {
const rawBuffer = await readFile(filePath);
const rawBuffer = await readStoredFile(file.storedName);
const validation = await validateImageBuffer(rawBuffer, file.originalName);
let decoded: Buffer<ArrayBuffer> = Buffer.from(rawBuffer);
if (validation.valid && validation.format === "heif") {