Migrate from Replit infrastructure: Neon→PostgreSQL, GCS→S3, ReplitAuth→email/password, add Dockerfile
This commit is contained in:
+64
-175
@@ -1,26 +1,22 @@
|
||||
import { Storage, File } from "@google-cloud/storage";
|
||||
import { S3Client, GetObjectCommand, PutObjectCommand, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
|
||||
const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106";
|
||||
const S3_ENDPOINT = process.env.S3_ENDPOINT || "https://fsn1.your-objectstorage.com";
|
||||
const S3_BUCKET = process.env.S3_BUCKET || "folxspeed";
|
||||
const S3_PREFIX = process.env.S3_PREFIX || "video-folx-tv"; // prefix within bucket
|
||||
const S3_PUBLIC_BASE = process.env.S3_PUBLIC_BASE || "https://folxvideos.b-cdn.net";
|
||||
|
||||
// Object storage client for Google Cloud Storage integration
|
||||
export const objectStorageClient = new Storage({
|
||||
const s3 = new S3Client({
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: S3_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
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",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
|
||||
},
|
||||
projectId: "",
|
||||
});
|
||||
|
||||
export class ObjectNotFoundError extends Error {
|
||||
@@ -34,187 +30,80 @@ export class ObjectNotFoundError extends Error {
|
||||
export class ObjectStorageService {
|
||||
constructor() {}
|
||||
|
||||
// Get public object search paths
|
||||
getPublicObjectSearchPaths(): Array<string> {
|
||||
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;
|
||||
return [`${S3_PREFIX}/public`];
|
||||
}
|
||||
|
||||
// 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;
|
||||
return `${S3_PREFIX}/.private`;
|
||||
}
|
||||
|
||||
// Get upload URL for thumbnail upload
|
||||
async getThumbnailUploadURL(): Promise<string> {
|
||||
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
|
||||
const key = `${this.getPrivateObjectDir()}/thumbnails/${objectId}`;
|
||||
const cmd = new PutObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key,
|
||||
ACL: "public-read",
|
||||
});
|
||||
return await getSignedUrl(s3, cmd, { expiresIn: 900 });
|
||||
}
|
||||
|
||||
// Download object to response
|
||||
async downloadObject(file: File, res: Response, cacheTtlSec: number = 3600) {
|
||||
async downloadObject(objectKey: string, res: Response, cacheTtlSec: number = 3600) {
|
||||
try {
|
||||
const [metadata] = await file.getMetadata();
|
||||
|
||||
const cmd = new GetObjectCommand({ Bucket: S3_BUCKET, Key: objectKey });
|
||||
const obj = await s3.send(cmd);
|
||||
res.set({
|
||||
"Content-Type": metadata.contentType || "application/octet-stream",
|
||||
"Content-Length": metadata.size,
|
||||
"Content-Type": obj.ContentType || "application/octet-stream",
|
||||
"Content-Length": String(obj.ContentLength || ""),
|
||||
"Cache-Control": `public, max-age=${cacheTtlSec}`,
|
||||
});
|
||||
|
||||
const stream = file.createReadStream();
|
||||
|
||||
const stream = obj.Body as Readable;
|
||||
stream.on("error", (err) => {
|
||||
console.error("Stream error:", err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Error streaming file" });
|
||||
}
|
||||
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" });
|
||||
if (!res.headersSent) res.status(500).json({ error: "Error downloading file" });
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectFile(objectPath: string): Promise<string> {
|
||||
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("/");
|
||||
const key = `${this.getPrivateObjectDir()}/${entityId}`;
|
||||
try {
|
||||
await s3.send(new HeadObjectCommand({ Bucket: S3_BUCKET, Key: key }));
|
||||
return key; // returns S3 key instead of File object
|
||||
} catch {
|
||||
throw new ObjectNotFoundError();
|
||||
}
|
||||
}
|
||||
|
||||
normalizeObjectPath(rawPath: string): string {
|
||||
// Convert old Replit/GCS URL or full S3 URL into /objects/{entityId}
|
||||
const privateDir = this.getPrivateObjectDir();
|
||||
const patterns = [
|
||||
`https://storage.googleapis.com/`,
|
||||
`${S3_PUBLIC_BASE}/`,
|
||||
`${S3_ENDPOINT}/${S3_BUCKET}/`,
|
||||
];
|
||||
let path = rawPath;
|
||||
for (const p of patterns) {
|
||||
if (path.startsWith(p)) {
|
||||
path = "/" + path.slice(p.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get object file from path
|
||||
async getObjectFile(objectPath: string): Promise<File> {
|
||||
if (!objectPath.startsWith("/objects/")) {
|
||||
throw new ObjectNotFoundError();
|
||||
const idx = path.indexOf(privateDir);
|
||||
if (idx >= 0) {
|
||||
const entityId = path.slice(idx + privateDir.length).replace(/^\//, "");
|
||||
return `/objects/${entityId}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
return rawPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user