Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/integrations/storageService.ts
T

376 lines
13 KiB
TypeScript

import { loadRequiredWebConfig } from "@toolctrl/config";
import {
createLocalStorageAdapter,
createS3StorageAdapter,
createMagicBytesScanAdapter,
resolveAllowList,
StorageAdapterError,
type PreUploadScanAdapter,
type PreUploadScanResult,
type ScanContext,
type StorageAdapter,
} from "@toolctrl/storage";
import { fetchOutboundHttpWithRedirects } from "~/utils/outboundHttp.server";
type StorageConfig = ReturnType<typeof loadRequiredWebConfig>["storage"];
type RuntimeProvider = "s3" | "local";
// Upload "content" can be caller-supplied. Only inline data: URIs are fetched
// directly; any remote URL is routed through the SSRF-safe helper, which
// rejects private/loopback/link-local/metadata addresses and validates the
// scheme and every redirect hop. A bare fetch() here would let an authenticated
// user reach cloud metadata or internal services (e.g. via "HTTP://169.254..."
// which the prior case-sensitive "http" check missed).
async function fetchUploadContent(content: string): Promise<Blob> {
const trimmed = (content ?? "").trim();
if (trimmed.toLowerCase().startsWith("data:")) {
return await (await fetch(trimmed)).blob();
}
const response = await fetchOutboundHttpWithRedirects(trimmed, {});
return await response.blob();
}
export class StorageScanRejectedError extends Error {
readonly bucket: string;
readonly id: string;
readonly filename: string;
readonly scanResult: PreUploadScanResult;
constructor(bucket: string, id: string, filename: string, scanResult: PreUploadScanResult) {
super(`Upload rejected by pre-upload scanner: ${scanResult.reason ?? "unknown"} (${scanResult.detail ?? ""})`);
this.name = "StorageScanRejectedError";
this.bucket = bucket;
this.id = id;
this.filename = filename;
this.scanResult = scanResult;
}
}
export type StoreFileInput = {
bucket: string;
content: string;
id: string;
replace?: boolean;
throwOnFailure?: boolean;
/**
* When set, the upload buffer is run through MagicBytesScanAdapter before
* the storage PUT. A rejected scan throws StorageScanRejectedError (always,
* regardless of throwOnFailure) so callers cannot accidentally swallow a
* security verdict. When unset the scan is skipped — that's only safe for
* server-internal uploads where the bytes never came from a user.
*/
scan?: {
context: ScanContext;
declaredMimeType?: string;
filename?: string;
/**
* Side-effect callback fired exactly once when the scanner returns a
* rejected verdict, BEFORE the StorageScanRejectedError is thrown. The
* callback may write an audit event or telemetry; storeFile awaits it so
* the audit record is persisted before the route gets a chance to
* swallow the error.
*/
onReject?: (verdict: PreUploadScanResult) => void | Promise<void>;
/**
* When true AND the layer-1 scan passes AND the storage PUT succeeds,
* storeFile enqueues a toolctrl.file-scan job so the worker runs ClamAV
* (layer 2) over the stored object. Requires SCAN_QUEUE_ENABLED=true
* on the runtime; otherwise the enqueue is a no-op. Failures to enqueue
* are logged but never throw — the synchronous user-visible upload
* should not block on a queue-write hiccup.
*/
enqueuePostScan?: boolean;
/**
* Optional tenant attribution for the post-upload scan job (carried
* through to the worker's audit row).
*/
tenantId?: string;
};
};
const defaultScanAdapter: PreUploadScanAdapter = createMagicBytesScanAdapter({
allowList: resolveAllowList(typeof process !== "undefined" ? (process.env as Record<string, string | undefined>) : {}),
enabled: typeof process !== "undefined" ? process.env.STORAGE_SCAN_LAYER1_ENABLED !== "false" : true,
});
export async function storeFile({ bucket, content, id, replace, throwOnFailure = false, scan }: StoreFileInput): Promise<string> {
if (content.trim().toLowerCase().startsWith("http") && !replace) {
if (throwOnFailure) {
throw new Error("Strict uploads require upload content, not an existing URL.");
}
return content;
}
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
if (throwOnFailure) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "storeFile",
bucket,
});
}
return content;
}
if (scan) {
const blob = await fetchUploadContent(content);
const body = new Uint8Array(await blob.arrayBuffer());
const declared = scan.declaredMimeType ?? blob.type ?? "application/octet-stream";
const filename = scan.filename ?? id;
const verdict = await defaultScanAdapter.scan({
buffer: body,
declaredMimeType: declared,
filename,
context: scan.context,
});
if (verdict.status === "rejected") {
if (scan.onReject) {
try {
await scan.onReject(verdict);
} catch {
// never let an audit-write failure mask the security verdict
}
}
throw new StorageScanRejectedError(bucket, id, filename, verdict);
}
let stored: string;
try {
stored = await putBufferViaProvider(primaryProvider, bucket, id, body, blob.type || undefined, config.storage);
} catch (error) {
const fallbackProvider = resolveFallbackProvider(config.storage, primaryProvider);
if (fallbackProvider) {
try {
stored = await putBufferViaProvider(fallbackProvider, bucket, id, body, blob.type || undefined, config.storage);
} catch (fallbackError) {
if (throwOnFailure) throw fallbackError;
return content;
}
} else {
if (throwOnFailure) throw error;
return content;
}
}
if (scan.enqueuePostScan) {
try {
const { enqueueFileScan } = await import("./fileScanQueue.server.js");
await enqueueFileScan({
bucket,
key: id,
tenantId: scan.tenantId,
contentType: blob.type || scan.declaredMimeType,
});
} catch {
// never block a successful upload on a queue write
}
}
return stored;
}
try {
return await putFileViaProvider(primaryProvider, bucket, id, content, config.storage);
} catch (error) {
const fallbackProvider = resolveFallbackProvider(config.storage, primaryProvider);
if (fallbackProvider) {
try {
return await putFileViaProvider(fallbackProvider, bucket, id, content, config.storage);
} catch (fallbackError) {
if (throwOnFailure) {
throw fallbackError;
}
return content;
}
}
if (throwOnFailure) {
throw error;
}
return content;
}
}
export async function putBufferAsFile({
bucket,
key,
body,
contentType,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
}): Promise<{ publicUrl: string | null }> {
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "putBufferAsFile",
bucket,
});
}
const adapter = createRuntimeStorageAdapter(primaryProvider, bucket, config.storage);
const result = await adapter.putObject({ key, body, contentType });
return { publicUrl: result.publicUrl ?? null };
}
export async function deleteFile({ bucket, key }: { bucket: string; key: string }): Promise<void> {
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
return;
}
const adapter = createRuntimeStorageAdapter(primaryProvider, bucket, config.storage);
await adapter.deleteObject({ key });
}
export function hasStorageCredentials(): boolean {
const config = loadRequiredWebConfig();
return resolvePrimaryStorageProvider(config.storage) !== undefined;
}
/**
* Default files bucket (STORAGE_BUCKET). The Files module (AK-P4a) stores all
* objects in this single private bucket; tenant isolation is carried by the
* `tenants/{tenantId}/...` key prefix (§2) plus app-level scoping/RLS.
*/
export function resolveStorageBucket(): string {
const config = loadRequiredWebConfig();
const bucket = config.storage.bucket;
if (!bucket || bucket.trim().length === 0) {
throw new StorageAdapterError("config_missing", "STORAGE_BUCKET is not configured.", {
operation: "resolveStorageBucket",
missingFields: ["bucket"],
});
}
return bucket;
}
/**
* Assemble previously-stored temp chunk objects into a single object via
* multipart UploadPartCopy (§3). `chunkSourceKeys` MUST be ordered by chunk
* index. Aborts the multipart upload on any failure so no dangling parts remain.
*/
export async function assembleChunkedUpload({
bucket,
targetKey,
chunkSourceKeys,
contentType,
}: {
bucket: string;
targetKey: string;
chunkSourceKeys: readonly string[];
contentType?: string;
}): Promise<{ publicUrl: string | null }> {
const config = loadRequiredWebConfig();
const provider = resolvePrimaryStorageProvider(config.storage);
if (!provider) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "assembleChunkedUpload",
bucket,
});
}
const adapter = createRuntimeStorageAdapter(provider, bucket, config.storage);
const handle = await adapter.createMultipartUpload({ key: targetKey, contentType });
try {
const parts = [];
for (let index = 0; index < chunkSourceKeys.length; index += 1) {
parts.push(
await adapter.uploadPartCopy({
key: targetKey,
uploadId: handle.uploadId,
partNumber: index + 1,
sourceKey: chunkSourceKeys[index],
}),
);
}
const result = await adapter.completeMultipartUpload({ key: targetKey, uploadId: handle.uploadId, parts });
return { publicUrl: result.publicUrl ?? null };
} catch (error) {
await adapter.abortMultipartUpload({ key: targetKey, uploadId: handle.uploadId }).catch(() => {});
throw error;
}
}
async function putFileViaProvider(provider: RuntimeProvider, bucket: string, id: string, content: string, storage: StorageConfig): Promise<string> {
const blob = await fetchUploadContent(content);
const body = new Uint8Array(await blob.arrayBuffer());
const contentType = blob.type || undefined;
return putBufferViaProvider(provider, bucket, id, body, contentType, storage);
}
async function putBufferViaProvider(
provider: RuntimeProvider,
bucket: string,
id: string,
body: Uint8Array,
contentType: string | undefined,
storage: StorageConfig
): Promise<string> {
const adapter = createRuntimeStorageAdapter(provider, bucket, storage);
const result = await adapter.putObject({ key: id, body, contentType });
return result.publicUrl ?? "";
}
function resolvePrimaryStorageProvider(storage: StorageConfig): RuntimeProvider | undefined {
if (storage.provider === "s3" || storage.provider === "local") {
return storage.provider;
}
if (hasCompleteS3StorageConfig(storage)) {
return "s3";
}
if (storage.localRoot) {
return "local";
}
return undefined;
}
function resolveFallbackProvider(storage: StorageConfig, primary: RuntimeProvider): RuntimeProvider | undefined {
const fallback = storage.fallbackProvider;
if ((fallback === "s3" || fallback === "local") && fallback !== primary) {
return fallback;
}
return undefined;
}
function createRuntimeStorageAdapter(provider: RuntimeProvider, bucket: string, storage: StorageConfig): StorageAdapter {
if (provider === "s3") {
if (!hasCompleteS3StorageConfig(storage)) {
throw new StorageAdapterError("config_missing", "S3 storage configuration is incomplete.", {
operation: "createRuntimeStorageAdapter",
bucket,
missingFields: ["endpoint", "region", "accessKeyId", "secretAccessKey"].filter((field) => !storage[field as keyof typeof storage]),
});
}
return createS3StorageAdapter({
endpoint: storage.endpoint,
region: storage.region,
bucket,
accessKeyId: storage.accessKeyId,
secretAccessKey: storage.secretAccessKey,
sessionToken: storage.sessionToken,
publicBaseUrl: storage.publicBaseUrl,
forcePathStyle: storage.forcePathStyle === undefined ? undefined : storage.forcePathStyle.toLowerCase() === "true",
});
}
if (!storage.localRoot) {
throw new StorageAdapterError("config_missing", "Local storage root is not configured.", {
operation: "createRuntimeStorageAdapter",
bucket,
missingFields: ["localRoot"],
});
}
return createLocalStorageAdapter({
rootDir: storage.localRoot,
bucket,
publicBaseUrl: storage.localPublicBaseUrl,
});
}
function hasCompleteS3StorageConfig(storage: StorageConfig): storage is StorageConfig & {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
} {
return Boolean(storage.endpoint && storage.region && storage.accessKeyId && storage.secretAccessKey);
}