videofolxtv/server/objectStorage.ts
sebastjanartic c71720454f Add admin dashboard and Replit authentication integration
Integrates Replit authentication using OpenID Connect, adds an admin dashboard route with video management and thumbnail upload capabilities, and updates dependencies.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 890577b1-c154-40a4-a177-a0c6d55320c3
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/890577b1-c154-40a4-a177-a0c6d55320c3/1jMBtLj
2025-09-02 12:01:00 +00:00

220 lines
5.7 KiB
TypeScript

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<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;
}
// 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<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
});
}
// 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<File> {
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<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;
}