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
220 lines
5.7 KiB
TypeScript
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;
|
|
} |