videofolxtv/server/objectStorage.ts

110 lines
3.6 KiB
TypeScript

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 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";
const s3 = new S3Client({
region: process.env.S3_REGION || "fsn1",
endpoint: S3_ENDPOINT,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
},
});
export class ObjectNotFoundError extends Error {
constructor() {
super("Object not found");
this.name = "ObjectNotFoundError";
Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
}
}
export class ObjectStorageService {
constructor() {}
getPublicObjectSearchPaths(): Array<string> {
return [`${S3_PREFIX}/public`];
}
getPrivateObjectDir(): string {
return `${S3_PREFIX}/.private`;
}
async getThumbnailUploadURL(): Promise<string> {
const objectId = randomUUID();
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 });
}
async downloadObject(objectKey: string, res: Response, cacheTtlSec: number = 3600) {
try {
const cmd = new GetObjectCommand({ Bucket: S3_BUCKET, Key: objectKey });
const obj = await s3.send(cmd);
res.set({
"Content-Type": obj.ContentType || "application/octet-stream",
"Content-Length": String(obj.ContentLength || ""),
"Cache-Control": `public, max-age=${cacheTtlSec}`,
});
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" });
});
stream.pipe(res);
} catch (error) {
console.error("Error downloading file:", error);
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;
}
}
const idx = path.indexOf(privateDir);
if (idx >= 0) {
const entityId = path.slice(idx + privateDir.length).replace(/^\//, "");
return `/objects/${entityId}`;
}
return rawPath;
}
}