videofolxtv/server/routes.ts
sebastjanartic d91a94675c Improve user ID generation for consistent authentication
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
2025-09-02 12:47:24 +00:00

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;
}