mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user