Migrate from Replit infrastructure: Neon→PostgreSQL, GCS→S3, ReplitAuth→email/password, add Dockerfile

This commit is contained in:
2026-06-07 14:33:49 +02:00
parent cc2445c4d9
commit bcbc76bf43
5 changed files with 135 additions and 348 deletions
+64 -175
View File
@@ -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;
}