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
This commit is contained in:
sebastjanartic
2025-09-02 12:01:00 +00:00
parent 12df511504
commit c71720454f
12 changed files with 975 additions and 31 deletions
+220
View File
@@ -0,0 +1,220 @@
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;
}