Implement deterministic UUID generation using SHA256 hashing for Replit user IDs to ensure consistent user identification across sessions and database operations. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 170e18f0-0f13-4eca-8643-546bba1dd8cc Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/170e18f0-0f13-4eca-8643-546bba1dd8cc/vqbrWR9
1086 lines
38 KiB
TypeScript
1086 lines
38 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import express from "express";
|
|
import compression from "compression";
|
|
import { storage } from "./storage";
|
|
import { z } from "zod";
|
|
import {
|
|
updateVideoSchema, insertVideoSchema, insertUserSchema,
|
|
insertVideoUploadSchema, insertCategorySchema, insertTagSchema,
|
|
type User, type VideoUpload
|
|
} from "@shared/schema";
|
|
import multer from "multer";
|
|
import { randomUUID } from "crypto";
|
|
import path from "path";
|
|
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" {
|
|
interface SessionData {
|
|
userId: string;
|
|
}
|
|
}
|
|
|
|
// Configure multer for video uploads
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: './uploads/videos',
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
|
}
|
|
}),
|
|
limits: {
|
|
fileSize: 500 * 1024 * 1024, // 500MB max file size
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Allow video files only
|
|
if (file.mimetype.startsWith('video/')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only video files are allowed'));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Simple session-based authentication middleware
|
|
const authenticate = (req: Request, res: Response, next: any) => {
|
|
if (req.session?.userId) {
|
|
next();
|
|
} else {
|
|
res.status(401).json({ message: "Authentication required" });
|
|
}
|
|
};
|
|
|
|
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,
|
|
threshold: 1024, // Only compress responses larger than 1KB
|
|
filter: (req: any, res: any) => {
|
|
// Don't compress video files
|
|
if (req.headers['accept']?.includes('video/')) {
|
|
return false;
|
|
}
|
|
return compression.filter(req, res);
|
|
}
|
|
}));
|
|
|
|
// Configure session middleware
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || 'dev-secret-key',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
|
}));
|
|
|
|
// Authentication Routes
|
|
app.post("/api/auth/register", async (req, res) => {
|
|
try {
|
|
const userData = insertUserSchema.parse(req.body);
|
|
|
|
// Check if user already exists
|
|
const existingUser = await storage.getUserByEmail(userData.email);
|
|
if (existingUser) {
|
|
return res.status(400).json({ message: "User already exists with this email" });
|
|
}
|
|
|
|
const user = await storage.createUser(userData);
|
|
|
|
// Remove password from response
|
|
const { passwordHash, ...userResponse } = user;
|
|
res.status(201).json(userResponse);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid user data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create user" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/login", async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ message: "Email and password are required" });
|
|
}
|
|
|
|
const user = await storage.validateUserPassword(email, password);
|
|
if (!user) {
|
|
return res.status(401).json({ message: "Invalid credentials" });
|
|
}
|
|
|
|
// Set session
|
|
req.session.userId = user.id;
|
|
|
|
// Remove password from response
|
|
const { passwordHash, ...userResponse } = user;
|
|
res.json(userResponse);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to login" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/logout", (req, res) => {
|
|
req.session?.destroy(() => {
|
|
res.json({ message: "Logged out successfully" });
|
|
});
|
|
});
|
|
|
|
app.get("/api/auth/me", authenticate, async (req, res) => {
|
|
try {
|
|
const user = await storage.getUser(req.session.userId!);
|
|
if (!user) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
const { passwordHash, ...userResponse } = user;
|
|
res.json(userResponse);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch user" });
|
|
}
|
|
});
|
|
|
|
// Video Routes
|
|
// Bunny.net administration routes
|
|
app.get("/api/bunny/stats", async (req, res) => {
|
|
try {
|
|
const videos = await storage.getVideos(1, 1000);
|
|
const totalViews = videos.length > 0 ? videos.reduce((sum: number, video: any) => sum + video.views, 0) : 0;
|
|
|
|
const stats = {
|
|
totalVideos: videos.length,
|
|
totalViews,
|
|
totalStorage: 0, // Would need separate Bunny API call
|
|
bandwidth: 0 // Would need separate Bunny API call
|
|
};
|
|
|
|
res.json(stats);
|
|
} catch (error) {
|
|
console.error("Error fetching Bunny stats:", error);
|
|
res.status(500).json({ message: "Failed to fetch statistics" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/bunny/videos/:id", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Note: This would need implementation in BunnyService
|
|
// For now, return success (deletion would happen in Bunny dashboard)
|
|
res.json({ message: "Video deletion initiated" });
|
|
} catch (error) {
|
|
console.error("Error deleting video:", error);
|
|
res.status(500).json({ message: "Failed to delete video" });
|
|
}
|
|
});
|
|
|
|
// Manual sync endpoint to force refresh from Bunny.net
|
|
app.post("/api/bunny/sync", async (req, res) => {
|
|
try {
|
|
console.log('🔄 Manual video sync requested...');
|
|
// Import videoSyncService to trigger manual sync
|
|
const { videoSyncService } = await import('./videoSync');
|
|
await videoSyncService.initialize();
|
|
|
|
const videos = await storage.getVideos(1, 1000);
|
|
res.json({
|
|
message: "Video sync completed successfully",
|
|
totalVideos: videos.length,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} catch (error) {
|
|
console.error("Error during manual sync:", error);
|
|
res.status(500).json({ message: "Failed to sync videos from Bunny.net" });
|
|
}
|
|
});
|
|
|
|
|
|
|
|
app.get("/api/videos", async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
const offset = parseInt(req.query.offset as string) || 0;
|
|
const search = req.query.search as string;
|
|
|
|
// Skip search for queries shorter than 2 characters for performance
|
|
const searchQuery = search && search.length >= 2 ? search : undefined;
|
|
|
|
// Create cache key for ETag
|
|
const cacheKey = `videos-${limit}-${offset}-${searchQuery || 'all'}`;
|
|
const etag = `"${cacheKey}"`;
|
|
|
|
// Check if client has cached version
|
|
if (req.headers['if-none-match'] === etag) {
|
|
return res.status(304).end();
|
|
}
|
|
|
|
// Set optimized cache headers
|
|
res.set({
|
|
'Cache-Control': 'public, max-age=120, stale-while-revalidate=300', // 2 min cache, 5 min stale
|
|
'ETag': etag,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Vary': 'Accept-Encoding'
|
|
});
|
|
|
|
console.log(`Fetching videos: limit=${limit}, offset=${offset}, search=${searchQuery}`);
|
|
|
|
const videos = await storage.getVideos(limit, offset, searchQuery);
|
|
const total = await storage.getVideoCount(searchQuery);
|
|
|
|
console.log(`Returning ${videos.length} videos`);
|
|
|
|
res.json({
|
|
videos,
|
|
total,
|
|
hasMore: offset + limit < total
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching videos:", error);
|
|
res.status(500).json({ message: "Failed to fetch videos" });
|
|
}
|
|
});
|
|
|
|
// Face detection endpoint for thumbnails
|
|
app.post("/api/videos/:id/analyze-face", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const video = await storage.getVideo(id);
|
|
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
if (!video.thumbnailUrl) {
|
|
return res.status(400).json({ message: "Video has no thumbnail" });
|
|
}
|
|
|
|
// Import smart thumbnail service (no external dependencies)
|
|
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
|
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
|
|
|
// Update video with face position data
|
|
await storage.updateVideo(id, {
|
|
faceCenterPosition: result.faceCenterPosition,
|
|
facesDetected: result.facesDetected,
|
|
faceConfidence: result.confidence
|
|
});
|
|
|
|
res.json({
|
|
videoId: id,
|
|
...result
|
|
});
|
|
} catch (error) {
|
|
console.error("Error analyzing face in thumbnail:", error);
|
|
res.status(500).json({ message: "Failed to analyze face" });
|
|
}
|
|
});
|
|
|
|
// Batch face analysis for all videos
|
|
app.post("/api/videos/batch-analyze-faces", async (req, res) => {
|
|
try {
|
|
// Get all videos that don't have face analysis yet
|
|
const allVideos = await storage.getVideos(1000, 0); // Get up to 1000 videos
|
|
const videosToProcess = allVideos.filter(video => !video.faceCenterPosition && video.thumbnailUrl);
|
|
|
|
if (videosToProcess.length === 0) {
|
|
return res.json({
|
|
message: "All videos already processed or no thumbnails available",
|
|
processed: 0,
|
|
total: allVideos.length
|
|
});
|
|
}
|
|
|
|
console.log(`Starting batch face analysis for ${videosToProcess.length} videos...`);
|
|
|
|
// Import smart thumbnail service (no external dependencies)
|
|
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
|
let processed = 0;
|
|
let failed = 0;
|
|
|
|
// Process videos in batches to avoid overwhelming the system
|
|
const batchSize = 5;
|
|
for (let i = 0; i < videosToProcess.length; i += batchSize) {
|
|
const batch = videosToProcess.slice(i, i + batchSize);
|
|
|
|
await Promise.all(batch.map(async (video) => {
|
|
try {
|
|
console.log(`Processing face detection for video: ${video.title} (${video.id})`);
|
|
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
|
|
|
await storage.updateVideo(video.id, {
|
|
faceCenterPosition: result.faceCenterPosition,
|
|
facesDetected: result.facesDetected,
|
|
faceConfidence: result.confidence
|
|
});
|
|
|
|
processed++;
|
|
console.log(`✅ Face analysis completed for ${video.title} - Faces detected: ${result.facesDetected}`);
|
|
} catch (error) {
|
|
console.error(`❌ Face analysis failed for ${video.title}:`, error);
|
|
failed++;
|
|
}
|
|
}));
|
|
|
|
// Small delay between batches to be respectful
|
|
if (i + batchSize < videosToProcess.length) {
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
}
|
|
|
|
console.log(`Batch face analysis completed: ${processed} processed, ${failed} failed`);
|
|
|
|
res.json({
|
|
message: "Batch face analysis completed",
|
|
processed,
|
|
failed,
|
|
total: videosToProcess.length
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in batch face analysis:", error);
|
|
res.status(500).json({ message: "Failed to perform batch face analysis" });
|
|
}
|
|
});
|
|
|
|
// Get single video by ID
|
|
app.get("/api/videos/:id", async (req, res) => {
|
|
try {
|
|
const video = await storage.getVideo(req.params.id);
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
res.json(video);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch video" });
|
|
}
|
|
});
|
|
|
|
// Get video ads/spots metadata from Bunny.net
|
|
app.get("/api/videos/:id/ads", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check if video exists first
|
|
const video = await storage.getVideo(id);
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
// Get ads from Bunny.net API
|
|
let ads: any[] = [];
|
|
try {
|
|
const { BunnyService } = await import("./bunny");
|
|
const bunnyService = new BunnyService();
|
|
ads = await bunnyService.getVideoAds(id);
|
|
console.log(`Retrieved ${ads.length} ad spots for video ${id}`);
|
|
} catch (error) {
|
|
console.error(`Failed to get ads from Bunny.net for video ${id}:`, error);
|
|
// Return empty array if Bunny service fails
|
|
ads = [];
|
|
}
|
|
|
|
res.json({
|
|
videoId: id,
|
|
ads: ads,
|
|
totalAds: ads.length
|
|
});
|
|
} catch (error) {
|
|
console.error(`Error fetching ads for video ${req.params.id}:`, error);
|
|
res.status(500).json({ message: "Failed to fetch video ads" });
|
|
}
|
|
});
|
|
|
|
// Update video views
|
|
app.post("/api/videos/:id/view", async (req, res) => {
|
|
try {
|
|
await storage.updateVideoViews(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to update views" });
|
|
}
|
|
});
|
|
|
|
// Create new video
|
|
app.post("/api/videos", authenticate, async (req, res) => {
|
|
try {
|
|
const videoData = insertVideoSchema.parse(req.body);
|
|
const video = await storage.createVideo(videoData);
|
|
res.status(201).json(video);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid video data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create video" });
|
|
}
|
|
});
|
|
|
|
// Update video metadata (title, description, etc.)
|
|
app.patch("/api/videos/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const updates = updateVideoSchema.parse(req.body);
|
|
const video = await storage.updateVideo(req.params.id, updates);
|
|
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
res.json(video);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid request data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to update video" });
|
|
}
|
|
});
|
|
|
|
// Delete video
|
|
app.delete("/api/videos/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const success = await storage.deleteVideo(req.params.id);
|
|
if (!success) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
res.json({ success: true, message: "Video deleted successfully" });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to delete video" });
|
|
}
|
|
});
|
|
|
|
// Video Upload Routes
|
|
app.post("/api/uploads/start", authenticate, async (req, res) => {
|
|
try {
|
|
const { originalFileName, fileSize, mimeType } = req.body;
|
|
|
|
if (!originalFileName || !fileSize || !mimeType) {
|
|
return res.status(400).json({
|
|
message: "originalFileName, fileSize, and mimeType are required"
|
|
});
|
|
}
|
|
|
|
const uploadData = {
|
|
userId: req.session.userId!,
|
|
originalFileName,
|
|
fileSize: parseInt(fileSize),
|
|
mimeType,
|
|
uploadStatus: "uploading" as const,
|
|
uploadProgress: 0
|
|
};
|
|
|
|
const upload = await storage.createVideoUpload(uploadData);
|
|
res.status(201).json(upload);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to initialize upload" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/uploads/:id/video", authenticate, upload.single('video'), async (req, res) => {
|
|
try {
|
|
const uploadId = req.params.id;
|
|
const file = req.file;
|
|
|
|
if (!file) {
|
|
return res.status(400).json({ message: "No video file provided" });
|
|
}
|
|
|
|
// Update upload with file information
|
|
await storage.updateVideoUpload(uploadId, {
|
|
uploadStatus: "processing",
|
|
uploadProgress: 1.0
|
|
});
|
|
|
|
// Create video record
|
|
const videoData = {
|
|
title: req.body.title || path.parse(file.originalname).name,
|
|
description: req.body.description || "",
|
|
thumbnailUrl: req.body.thumbnailUrl || "https://via.placeholder.com/800x450",
|
|
videoUrl: `/uploads/videos/${file.filename}`,
|
|
duration: parseInt(req.body.duration) || 0,
|
|
views: 0,
|
|
category: req.body.category || "",
|
|
tags: req.body.tags ? JSON.parse(req.body.tags) : [],
|
|
isPublic: req.body.isPublic !== "false",
|
|
uploadStatus: "completed",
|
|
originalFileName: file.originalname,
|
|
fileSize: file.size,
|
|
format: path.extname(file.originalname).slice(1)
|
|
};
|
|
|
|
const video = await storage.createVideo(videoData);
|
|
|
|
// Link video to upload
|
|
await storage.updateVideoUpload(uploadId, {
|
|
videoId: video.id,
|
|
uploadStatus: "completed"
|
|
});
|
|
|
|
res.json({ video, upload: { id: uploadId, status: "completed" } });
|
|
} catch (error) {
|
|
console.error("Upload error:", error);
|
|
|
|
// Update upload status to failed
|
|
if (req.params.id) {
|
|
await storage.updateVideoUpload(req.params.id, {
|
|
uploadStatus: "failed",
|
|
errorMessage: error instanceof Error ? error.message : "Upload failed"
|
|
});
|
|
}
|
|
|
|
res.status(500).json({ message: "Failed to upload video" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/uploads/:id/status", authenticate, async (req, res) => {
|
|
try {
|
|
const upload = await storage.getVideoUpload(req.params.id);
|
|
if (!upload) {
|
|
return res.status(404).json({ message: "Upload not found" });
|
|
}
|
|
res.json(upload);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to get upload status" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/uploads/user", authenticate, async (req, res) => {
|
|
try {
|
|
const uploads = await storage.getUserVideoUploads(req.session.userId!);
|
|
res.json(uploads);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch user uploads" });
|
|
}
|
|
});
|
|
|
|
// Category Routes
|
|
app.get("/api/categories", async (req, res) => {
|
|
try {
|
|
const categories = await storage.getCategories();
|
|
res.json(categories);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch categories" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/categories", authenticate, async (req, res) => {
|
|
try {
|
|
const categoryData = insertCategorySchema.parse(req.body);
|
|
const category = await storage.createCategory(categoryData);
|
|
res.status(201).json(category);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create category" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/categories/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const updates = insertCategorySchema.partial().parse(req.body);
|
|
const category = await storage.updateCategory(req.params.id, updates);
|
|
|
|
if (!category) {
|
|
return res.status(404).json({ message: "Category not found" });
|
|
}
|
|
|
|
res.json(category);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to update category" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/categories/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const success = await storage.deleteCategory(req.params.id);
|
|
if (!success) {
|
|
return res.status(404).json({ message: "Category not found" });
|
|
}
|
|
res.json({ success: true, message: "Category deleted successfully" });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to delete category" });
|
|
}
|
|
});
|
|
|
|
// Tag Routes
|
|
app.get("/api/tags", async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit as string) || 50;
|
|
const popular = req.query.popular === "true";
|
|
|
|
const tags = popular
|
|
? await storage.getPopularTags(limit)
|
|
: await storage.getTags();
|
|
|
|
res.json(tags);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch tags" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/tags", authenticate, async (req, res) => {
|
|
try {
|
|
const tagData = insertTagSchema.parse(req.body);
|
|
const tag = await storage.createTag(tagData);
|
|
res.status(201).json(tag);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid tag data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create tag" });
|
|
}
|
|
});
|
|
|
|
// Serve uploaded videos
|
|
app.use('/uploads', express.static('uploads'));
|
|
|
|
|
|
|
|
|
|
|
|
// Serve ads.txt file specifically
|
|
app.get("/ads.txt", (req, res) => {
|
|
res.type("text/plain");
|
|
res.sendFile(path.join(__dirname, "../client/public/ads.txt"));
|
|
});
|
|
|
|
// Open Graph image generation endpoint
|
|
app.get('/api/og-image', (req, res) => {
|
|
try {
|
|
// Generate SVG-based image for Open Graph
|
|
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:hsl(250, 50%, 15%);stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:hsl(240, 30%, 25%);stop-opacity:1" />
|
|
</linearGradient>
|
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<rect width="1200" height="630" fill="url(#bgGradient)"/>
|
|
|
|
<polygon points="100,100 180,180 70,180" fill="#6366f1" opacity="0.1"/>
|
|
<polygon points="1000,200 1120,280 880,280" fill="#8b5cf6" opacity="0.08"/>
|
|
<polygon points="200,450 260,510 140,510" fill="#6366f1" opacity="0.12"/>
|
|
<polygon points="950,500 1040,590 860,590" fill="#8b5cf6" opacity="0.06"/>
|
|
|
|
<rect x="300" y="250" width="60" height="60" rx="15" fill="url(#logoGradient)"/>
|
|
|
|
<polygon points="321,265 321,295 342,280" fill="white"/>
|
|
|
|
<text x="400" y="330" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="white">go4.video</text>
|
|
|
|
<text x="400" y="380" font-family="Arial, sans-serif" font-size="32" fill="rgba(255,255,255,0.8)">Professional Video Streaming Platform</text>
|
|
|
|
<text x="400" y="430" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.6)">Geschichte des Liedes • FOLX STADL • Premium Content</text>
|
|
</svg>`;
|
|
|
|
res.setHeader('Content-Type', 'image/svg+xml');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
res.send(svg);
|
|
} catch (error) {
|
|
console.error('Error generating OG image:', error);
|
|
res.status(500).send('Error generating image');
|
|
}
|
|
});
|
|
|
|
// Favicon generation endpoint
|
|
app.get('/api/favicon', async (req, res) => {
|
|
try {
|
|
const size = req.query.size ? parseInt(req.query.size as string) : 32;
|
|
const format = req.query.format as string || 'svg';
|
|
const padding = Math.max(2, size * 0.1);
|
|
const logoSize = size - (padding * 2);
|
|
const cornerRadius = Math.max(2, size * 0.15);
|
|
|
|
if (format === 'png') {
|
|
// Ustvarimo PNG ikono za iOS PWA
|
|
// Ustvarimo SVG za pretvorbo v PNG
|
|
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- iOS stil ozadje z rounded corners -->
|
|
<rect x="0" y="0" width="${size}" height="${size}" rx="${cornerRadius * 1.5}" fill="url(#logoGradient)"/>
|
|
|
|
<!-- Bel play triangle v sredini -->
|
|
<polygon points="${padding + logoSize * 0.35},${padding + logoSize * 0.25} ${padding + logoSize * 0.35},${padding + logoSize * 0.75} ${padding + logoSize * 0.65},${padding + logoSize * 0.5}" fill="white" opacity="0.95"/>
|
|
|
|
<!-- go4 besedilo pod trikotnikom -->
|
|
${size >= 120 ? `<text x="${size/2}" y="${size * 0.8}" font-family="Arial, sans-serif" font-size="${size * 0.12}" font-weight="bold" fill="white" text-anchor="middle" opacity="0.9">go4</text>` : ''}
|
|
</svg>`;
|
|
|
|
// Pretvorimo SVG v PNG z Sharp
|
|
const pngBuffer = await sharp(Buffer.from(svg))
|
|
.png({ quality: 100, compressionLevel: 0 })
|
|
.toBuffer();
|
|
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
res.send(pngBuffer);
|
|
} else {
|
|
// Originalni SVG favicon
|
|
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- Logo background -->
|
|
<rect x="${padding}" y="${padding}" width="${logoSize}" height="${logoSize}" rx="${cornerRadius}" fill="url(#logoGradient)"/>
|
|
|
|
<!-- Play triangle -->
|
|
<polygon points="${padding + logoSize * 0.35},${padding + logoSize * 0.25} ${padding + logoSize * 0.35},${padding + logoSize * 0.75} ${padding + logoSize * 0.65},${padding + logoSize * 0.5}" fill="white"/>
|
|
</svg>`;
|
|
|
|
res.setHeader('Content-Type', 'image/svg+xml');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
res.send(svg);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error generating favicon:', error);
|
|
res.status(500).send('Error generating favicon');
|
|
}
|
|
});
|
|
|
|
// Social media thumbnail generation endpoint
|
|
app.get('/api/video-thumbnail/:videoId', async (req, res) => {
|
|
try {
|
|
const { videoId } = req.params;
|
|
const video = await storage.getVideo(videoId);
|
|
|
|
if (!video) {
|
|
return res.status(404).send('Video not found');
|
|
}
|
|
|
|
// Uporabimo čisti thumbnail iz Bunny.net
|
|
if (video.thumbnailUrl) {
|
|
try {
|
|
// Prenesemo dejanski thumbnail iz Bunny.net
|
|
const response = await fetch(video.thumbnailUrl);
|
|
if (response.ok) {
|
|
const thumbnailBuffer = await response.arrayBuffer();
|
|
|
|
// Preprosto povečamo thumbnail na social media velikost (1200x630) brez overlay-a
|
|
const resizedBuffer = await sharp(Buffer.from(thumbnailBuffer))
|
|
.resize(1200, 630, { fit: 'cover', position: 'center' })
|
|
.png({ quality: 90, compressionLevel: 6 })
|
|
.toBuffer();
|
|
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache za 1 uro
|
|
return res.send(resizedBuffer);
|
|
}
|
|
} catch (fetchError) {
|
|
console.log('Failed to fetch real thumbnail, falling back to generated:', fetchError);
|
|
}
|
|
}
|
|
|
|
// Fallback: ustvarimo generirani thumbnail z video informacijami
|
|
const width = 1200;
|
|
const height = 630; // Facebook/Twitter optimalne dimenzije
|
|
|
|
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#2D1B69;stop-opacity:1" />
|
|
<stop offset="50%" style="stop-color:#6366f1;stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
|
</linearGradient>
|
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.9" />
|
|
<stop offset="100%" style="stop-color:#ffffff;stop-opacity:0.7" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<!-- Ozadje -->
|
|
<rect width="${width}" height="${height}" fill="url(#bgGradient)"/>
|
|
|
|
<!-- Dekorativni elementi -->
|
|
<polygon points="100,100 180,180 70,180" fill="#ffffff" opacity="0.1"/>
|
|
<polygon points="1000,200 1120,280 880,280" fill="#ffffff" opacity="0.08"/>
|
|
<polygon points="200,450 260,510 140,510" fill="#ffffff" opacity="0.12"/>
|
|
<polygon points="950,500 1040,590 860,590" fill="#ffffff" opacity="0.06"/>
|
|
|
|
<!-- Glavni play button v levi strani -->
|
|
<circle cx="300" cy="315" r="80" fill="url(#logoGradient)" opacity="0.9"/>
|
|
<polygon points="270,285 270,345 330,315" fill="#6366f1"/>
|
|
|
|
<!-- Video naslov -->
|
|
<text x="450" y="250" font-family="Arial, sans-serif" font-size="42" font-weight="bold" fill="white" text-anchor="start">${video.title.length > 35 ? video.title.substring(0, 32) + '...' : video.title}</text>
|
|
|
|
<!-- Video opis -->
|
|
<text x="450" y="300" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.8)" text-anchor="start">${video.description && video.description.length > 60 ? video.description.substring(0, 57) + '...' : video.description || 'Watch this video on go4.video'}</text>
|
|
|
|
<!-- go4.video logo -->
|
|
<text x="450" y="400" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="start">go4.video</text>
|
|
<text x="450" y="430" font-family="Arial, sans-serif" font-size="18" fill="rgba(255,255,255,0.7)" text-anchor="start">Professional Video Streaming Platform</text>
|
|
|
|
<!-- Video dolžina -->
|
|
<rect x="950" y="500" width="200" height="40" rx="20" fill="rgba(0,0,0,0.7)"/>
|
|
<text x="1050" y="525" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">${Math.floor(video.duration / 60)}:${(video.duration % 60).toString().padStart(2, '0')}</text>
|
|
</svg>`;
|
|
|
|
// Pretvorimo SVG v PNG
|
|
const pngBuffer = await sharp(Buffer.from(svg))
|
|
.png({ quality: 90, compressionLevel: 6 })
|
|
.toBuffer();
|
|
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache za 1 uro
|
|
res.send(pngBuffer);
|
|
|
|
} catch (error) {
|
|
console.error('Error generating video thumbnail:', error);
|
|
res.status(500).send('Error generating thumbnail');
|
|
}
|
|
});
|
|
|
|
// ===== ADMIN ROUTES =====
|
|
|
|
// Auth route to get current user
|
|
app.get('/api/auth/user', isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
// Import the generateDeterministicUUID function from replitAuth
|
|
const { createHash } = await import('crypto');
|
|
function generateDeterministicUUID(replitId: string): string {
|
|
const hash = createHash('sha256').update(`replit_${replitId}`).digest('hex');
|
|
return [
|
|
hash.substring(0, 8),
|
|
hash.substring(8, 12),
|
|
'4' + hash.substring(13, 16),
|
|
(parseInt(hash.substring(16, 17), 16) & 0x3 | 0x8).toString(16) + hash.substring(17, 20),
|
|
hash.substring(20, 32)
|
|
].join('-');
|
|
}
|
|
|
|
const userId = generateDeterministicUUID(req.user.claims.sub);
|
|
const user = await storage.getUser(userId);
|
|
if (!user) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
// Remove sensitive data
|
|
const { passwordHash, ...userResponse } = user;
|
|
res.json(userResponse);
|
|
} 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 ROUTES (ADMIN) =====
|
|
|
|
// Get all users (admin only)
|
|
app.get('/api/admin/users', 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;
|
|
|
|
// For now, get all users - in production you'd want pagination
|
|
const users = await storage.getAllUsers ? await storage.getAllUsers(limit, offset, search) : [];
|
|
|
|
// Remove password hashes from response
|
|
const safeUsers = users.map(({ passwordHash, ...user }) => user);
|
|
|
|
res.json({
|
|
users: safeUsers,
|
|
total: users.length,
|
|
limit,
|
|
offset
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching users:", error);
|
|
res.status(500).json({ message: "Failed to fetch users" });
|
|
}
|
|
});
|
|
|
|
// Get user by ID (admin only)
|
|
app.get('/api/admin/users/:id', isAdmin, async (req, res) => {
|
|
try {
|
|
const user = await storage.getUser(req.params.id);
|
|
if (!user) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
// Remove password hash from response
|
|
const { passwordHash, ...safeUser } = user;
|
|
res.json(safeUser);
|
|
} catch (error) {
|
|
console.error("Error fetching user:", error);
|
|
res.status(500).json({ message: "Failed to fetch user" });
|
|
}
|
|
});
|
|
|
|
// Update user admin status (super admin only)
|
|
app.patch('/api/admin/users/:id/admin', isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = `replit_${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" });
|
|
}
|
|
|
|
// Remove password hash from response
|
|
const { passwordHash, ...safeUser } = updatedUser;
|
|
res.json(safeUser);
|
|
} catch (error) {
|
|
console.error("Error updating user admin status:", error);
|
|
res.status(500).json({ message: "Failed to update user" });
|
|
}
|
|
});
|
|
|
|
// Update user active status (admin only)
|
|
app.patch('/api/admin/users/:id/status', isAdmin, async (req, res) => {
|
|
try {
|
|
const userId = req.params.id;
|
|
const { isActive } = req.body;
|
|
|
|
if (typeof isActive !== 'boolean') {
|
|
return res.status(400).json({ message: "isActive must be a boolean" });
|
|
}
|
|
|
|
const updatedUser = await storage.updateUser(userId, { isActive });
|
|
if (!updatedUser) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
// Remove password hash from response
|
|
const { passwordHash, ...safeUser } = updatedUser;
|
|
res.json(safeUser);
|
|
} catch (error) {
|
|
console.error("Error updating user status:", error);
|
|
res.status(500).json({ message: "Failed to update user status" });
|
|
}
|
|
});
|
|
|
|
// Get admin statistics (admin only)
|
|
app.get('/api/admin/stats', isAdmin, async (req, res) => {
|
|
try {
|
|
const videoCount = await storage.getVideoCount();
|
|
const userCount = await storage.getUserCount ? await storage.getUserCount() : 0;
|
|
|
|
// Get recent videos
|
|
const recentVideos = await storage.getVideos(5, 0);
|
|
|
|
// Basic stats - in production you might want more sophisticated analytics
|
|
const stats = {
|
|
videos: {
|
|
total: videoCount,
|
|
recent: recentVideos.length
|
|
},
|
|
users: {
|
|
total: userCount,
|
|
// Could add more user analytics here
|
|
},
|
|
platform: {
|
|
uptime: process.uptime(),
|
|
nodeVersion: process.version
|
|
}
|
|
};
|
|
|
|
res.json(stats);
|
|
} catch (error) {
|
|
console.error("Error fetching admin stats:", error);
|
|
res.status(500).json({ message: "Failed to fetch statistics" });
|
|
}
|
|
});
|
|
|
|
const httpServer = createServer(app);
|
|
return httpServer;
|
|
}
|