110 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|