import { Storage, File } from "@google-cloud/storage"; import { Response } from "express"; import { randomUUID } from "crypto"; const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106"; // Object storage client for Google Cloud Storage integration export const objectStorageClient = new Storage({ credentials: { audience: "replit", subject_token_type: "access_token", token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`, type: "external_account", credential_source: { url: `${REPLIT_SIDECAR_ENDPOINT}/credential`, format: { type: "json", subject_token_field_name: "access_token", }, }, universe_domain: "googleapis.com", }, projectId: "", }); export class ObjectNotFoundError extends Error { constructor() { super("Object not found"); this.name = "ObjectNotFoundError"; Object.setPrototypeOf(this, ObjectNotFoundError.prototype); } } export class ObjectStorageService { constructor() {} // Get public object search paths getPublicObjectSearchPaths(): Array { const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || ""; const paths = Array.from( new Set( pathsStr .split(",") .map((path) => path.trim()) .filter((path) => path.length > 0) ) ); if (paths.length === 0) { throw new Error( "PUBLIC_OBJECT_SEARCH_PATHS not set. Object storage not configured." ); } return paths; } // Get private object directory getPrivateObjectDir(): string { const dir = process.env.PRIVATE_OBJECT_DIR || ""; if (!dir) { throw new Error( "PRIVATE_OBJECT_DIR not set. Object storage not configured." ); } return dir; } // Get upload URL for thumbnail upload async getThumbnailUploadURL(): Promise { const privateObjectDir = this.getPrivateObjectDir(); const objectId = randomUUID(); const fullPath = `${privateObjectDir}/thumbnails/${objectId}`; const { bucketName, objectName } = parseObjectPath(fullPath); return signObjectURL({ bucketName, objectName, method: "PUT", ttlSec: 900, // 15 minutes }); } // Download object to response async downloadObject(file: File, res: Response, cacheTtlSec: number = 3600) { try { const [metadata] = await file.getMetadata(); res.set({ "Content-Type": metadata.contentType || "application/octet-stream", "Content-Length": metadata.size, "Cache-Control": `public, max-age=${cacheTtlSec}`, }); const stream = file.createReadStream(); stream.on("error", (err) => { console.error("Stream error:", err); if (!res.headersSent) { res.status(500).json({ error: "Error streaming file" }); } }); stream.pipe(res); } catch (error) { console.error("Error downloading file:", error); if (!res.headersSent) { res.status(500).json({ error: "Error downloading file" }); } } } // Get object file from path async getObjectFile(objectPath: string): Promise { if (!objectPath.startsWith("/objects/")) { throw new ObjectNotFoundError(); } const parts = objectPath.slice(1).split("/"); if (parts.length < 2) { throw new ObjectNotFoundError(); } const entityId = parts.slice(1).join("/"); let entityDir = this.getPrivateObjectDir(); if (!entityDir.endsWith("/")) { entityDir = `${entityDir}/`; } const objectEntityPath = `${entityDir}${entityId}`; const { bucketName, objectName } = parseObjectPath(objectEntityPath); const bucket = objectStorageClient.bucket(bucketName); const objectFile = bucket.file(objectName); const [exists] = await objectFile.exists(); if (!exists) { throw new ObjectNotFoundError(); } return objectFile; } // Normalize object path from storage URL to local path normalizeObjectPath(rawPath: string): string { if (!rawPath.startsWith("https://storage.googleapis.com/")) { return rawPath; } const url = new URL(rawPath); const rawObjectPath = url.pathname; let objectEntityDir = this.getPrivateObjectDir(); if (!objectEntityDir.endsWith("/")) { objectEntityDir = `${objectEntityDir}/`; } if (!rawObjectPath.startsWith(objectEntityDir)) { return rawObjectPath; } const entityId = rawObjectPath.slice(objectEntityDir.length); return `/objects/${entityId}`; } } // Parse object path to bucket and object name function parseObjectPath(path: string): { bucketName: string; objectName: string; } { if (!path.startsWith("/")) { path = `/${path}`; } const pathParts = path.split("/"); if (pathParts.length < 3) { throw new Error("Invalid path: must contain at least a bucket name"); } const bucketName = pathParts[1]; const objectName = pathParts.slice(2).join("/"); return { bucketName, objectName, }; } // Sign object URL for upload/download async function signObjectURL({ bucketName, objectName, method, ttlSec, }: { bucketName: string; objectName: string; method: "GET" | "PUT" | "DELETE" | "HEAD"; ttlSec: number; }): Promise { const request = { bucket_name: bucketName, object_name: objectName, method, expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(), }; const response = await fetch( `${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(request), } ); if (!response.ok) { throw new Error( `Failed to sign object URL, errorcode: ${response.status}` ); } const { signed_url: signedURL } = await response.json(); return signedURL; }