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;
}
+172
View File
@@ -0,0 +1,172 @@
import * as client from "openid-client";
import { Strategy, type VerifyFunction } from "openid-client/passport";
import passport from "passport";
import session from "express-session";
import type { Express, RequestHandler } from "express";
import memoize from "memoizee";
import connectPg from "connect-pg-simple";
import { storage } from "./storage";
if (!process.env.REPLIT_DOMAINS) {
throw new Error("Environment variable REPLIT_DOMAINS not provided");
}
const getOidcConfig = memoize(
async () => {
return await client.discovery(
new URL(process.env.ISSUER_URL ?? "https://replit.com/oidc"),
process.env.REPL_ID!
);
},
{ maxAge: 3600 * 1000 }
);
export function getSession() {
const sessionTtl = 7 * 24 * 60 * 60 * 1000; // 1 week
const pgStore = connectPg(session);
const sessionStore = new pgStore({
conString: process.env.DATABASE_URL,
createTableIfMissing: false,
ttl: sessionTtl,
tableName: "sessions",
});
return session({
secret: process.env.SESSION_SECRET!,
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: sessionTtl,
},
});
}
function updateUserSession(
user: any,
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers
) {
user.claims = tokens.claims();
user.access_token = tokens.access_token;
user.refresh_token = tokens.refresh_token;
user.expires_at = user.claims?.exp;
}
async function upsertUser(claims: any) {
await storage.upsertUser({
id: claims["sub"],
email: claims["email"],
firstName: claims["first_name"],
lastName: claims["last_name"],
profileImageUrl: claims["profile_image_url"],
});
}
export async function setupAuth(app: Express) {
app.set("trust proxy", 1);
app.use(getSession());
app.use(passport.initialize());
app.use(passport.session());
const config = await getOidcConfig();
const verify: VerifyFunction = async (
tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers,
verified: passport.AuthenticateCallback
) => {
const user = {};
updateUserSession(user, tokens);
await upsertUser(tokens.claims());
verified(null, user);
};
for (const domain of process.env.REPLIT_DOMAINS!.split(",")) {
const strategy = new Strategy(
{
name: `replitauth:${domain}`,
config,
scope: "openid email profile offline_access",
callbackURL: `https://${domain}/api/callback`,
},
verify,
);
passport.use(strategy);
}
passport.serializeUser((user: Express.User, cb) => cb(null, user));
passport.deserializeUser((user: Express.User, cb) => cb(null, user));
app.get("/api/login", (req, res, next) => {
passport.authenticate(`replitauth:${req.hostname}`, {
prompt: "login consent",
scope: ["openid", "email", "profile", "offline_access"],
})(req, res, next);
});
app.get("/api/callback", (req, res, next) => {
passport.authenticate(`replitauth:${req.hostname}`, {
successReturnToOrRedirect: "/admin",
failureRedirect: "/api/login",
})(req, res, next);
});
app.get("/api/logout", (req, res) => {
req.logout(() => {
res.redirect(
client.buildEndSessionUrl(config, {
client_id: process.env.REPL_ID!,
post_logout_redirect_uri: `${req.protocol}://${req.hostname}`,
}).href
);
});
});
}
export const isAuthenticated: RequestHandler = async (req, res, next) => {
const user = req.user as any;
if (!req.isAuthenticated() || !user.expires_at) {
return res.status(401).json({ message: "Unauthorized" });
}
const now = Math.floor(Date.now() / 1000);
if (now <= user.expires_at) {
return next();
}
const refreshToken = user.refresh_token;
if (!refreshToken) {
res.status(401).json({ message: "Unauthorized" });
return;
}
try {
const config = await getOidcConfig();
const tokenResponse = await client.refreshTokenGrant(config, refreshToken);
updateUserSession(user, tokenResponse);
return next();
} catch (error) {
res.status(401).json({ message: "Unauthorized" });
return;
}
};
export const isAdmin: RequestHandler = async (req, res, next) => {
const user = req.user as any;
if (!req.isAuthenticated() || !user.claims?.sub) {
return res.status(401).json({ message: "Unauthorized" });
}
try {
const dbUser = await storage.getUser(user.claims.sub);
if (!dbUser || !dbUser.isAdmin) {
return res.status(403).json({ message: "Admin access required" });
}
next();
} catch (error) {
console.error("Error checking admin status:", error);
res.status(500).json({ message: "Internal server error" });
}
};
+109
View File
@@ -16,6 +16,8 @@ import fs from "fs";
import session from "express-session";
import sharp from "sharp";
import fetch from "node-fetch";
import { setupAuth, isAuthenticated, isAdmin } from "./replitAuth";
import { ObjectStorageService, ObjectNotFoundError } from "./objectStorage";
// Extend express session
declare module "express-session" {
@@ -56,6 +58,9 @@ const authenticate = (req: Request, res: Response, next: any) => {
};
export async function registerRoutes(app: Express): Promise<Server> {
// Setup Replit Auth first
await setupAuth(app);
// Add compression middleware for better performance
app.use(compression({
level: 6,
@@ -850,6 +855,110 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ===== ADMIN ROUTES =====
// Auth route to get current user
app.get('/api/auth/user', isAuthenticated, async (req: any, res) => {
try {
const userId = req.user.claims.sub;
const user = await storage.getUser(userId);
res.json(user);
} catch (error) {
console.error("Error fetching user:", error);
res.status(500).json({ message: "Failed to fetch user" });
}
});
// Admin video management
app.get('/api/admin/videos', isAdmin, async (req, res) => {
try {
const limit = parseInt(req.query.limit as string) || 50;
const offset = parseInt(req.query.offset as string) || 0;
const search = req.query.search as string;
const videos = await storage.getVideos(limit, offset, search);
const total = await storage.getVideoCount(search);
res.json({ videos, total, limit, offset });
} catch (error) {
console.error("Error fetching admin videos:", error);
res.status(500).json({ message: "Failed to fetch videos" });
}
});
app.patch('/api/admin/videos/:id', isAdmin, async (req, res) => {
try {
const videoId = req.params.id;
const updateData = updateVideoSchema.parse(req.body);
const updatedVideo = await storage.updateVideo(videoId, updateData);
if (!updatedVideo) {
return res.status(404).json({ message: "Video not found" });
}
res.json(updatedVideo);
} catch (error) {
console.error("Error updating video:", error);
if (error instanceof z.ZodError) {
res.status(400).json({ message: "Invalid video data", errors: error.errors });
} else {
res.status(500).json({ message: "Failed to update video" });
}
}
});
// Thumbnail upload
app.post('/api/admin/thumbnails/upload', isAdmin, async (req, res) => {
try {
const objectStorageService = new ObjectStorageService();
const uploadURL = await objectStorageService.getThumbnailUploadURL();
res.json({ uploadURL });
} catch (error) {
console.error("Error getting thumbnail upload URL:", error);
res.status(500).json({ message: "Failed to get upload URL" });
}
});
// Serve uploaded objects
app.get("/objects/:objectPath(*)", async (req, res) => {
const objectStorageService = new ObjectStorageService();
try {
const objectFile = await objectStorageService.getObjectFile(req.path);
objectStorageService.downloadObject(objectFile, res);
} catch (error) {
console.error("Error serving object:", error);
if (error instanceof ObjectNotFoundError) {
return res.sendStatus(404);
}
return res.sendStatus(500);
}
});
// User management (super admin only)
app.patch('/api/admin/users/:id/admin', isAuthenticated, async (req: any, res) => {
try {
const currentUserId = req.user.claims.sub;
const currentUser = await storage.getUser(currentUserId);
if (!currentUser?.isSuperAdmin) {
return res.status(403).json({ message: "Super admin access required" });
}
const userId = req.params.id;
const { isAdmin } = req.body;
const updatedUser = await storage.updateUser(userId, { isAdmin });
if (!updatedUser) {
return res.status(404).json({ message: "User not found" });
}
res.json(updatedUser);
} catch (error) {
console.error("Error updating user admin status:", error);
res.status(500).json({ message: "Failed to update user" });
}
});
const httpServer = createServer(app);
return httpServer;
}
+52
View File
@@ -29,6 +29,7 @@ export interface IStorage {
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
upsertUser(user: any): Promise<User>;
validateUserPassword(email: string, password: string): Promise<User | null>;
// Upload operations
@@ -166,6 +167,34 @@ export class DatabaseStorage implements IStorage {
return result[0];
}
async upsertUser(userData: any): Promise<User> {
const [user] = await db
.insert(users)
.values({
id: userData.id,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
profileImageUrl: userData.profileImageUrl,
username: userData.email || `user_${userData.id}`,
password: '', // No password for OAuth users
isAdmin: false,
isSuperAdmin: false,
})
.onConflictDoUpdate({
target: users.id,
set: {
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
profileImageUrl: userData.profileImageUrl,
updatedAt: new Date(),
},
})
.returning();
return user;
}
async validateUserPassword(email: string, password: string): Promise<User | null> {
const user = await this.getUserByEmail(email);
if (!user) return null;
@@ -521,6 +550,25 @@ export class MemStorage implements IStorage {
return updatedUser;
}
async upsertUser(userData: any): Promise<User> {
const existingUser = this.users.get(userData.id);
const user: User = {
id: userData.id,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
profileImageUrl: userData.profileImageUrl,
username: userData.email || `user_${userData.id}`,
password: '',
isAdmin: existingUser?.isAdmin || false,
isSuperAdmin: existingUser?.isSuperAdmin || false,
createdAt: existingUser?.createdAt || new Date(),
updatedAt: new Date(),
};
this.users.set(userData.id, user);
return user;
}
async validateUserPassword(email: string, password: string): Promise<User | null> {
const user = await this.getUserByEmail(email);
if (!user) return null;
@@ -759,6 +807,10 @@ class BunnyStorage implements IStorage {
throw new Error("User operations are not supported with Bunny.net integration");
}
async upsertUser(userData: any): Promise<User> {
throw new Error("User operations are not supported with Bunny.net integration");
}
async validateUserPassword(email: string, password: string): Promise<User | null> {
throw new Error("User operations are not supported with Bunny.net integration");
}