videofolxtv/server/bunny.ts
sebastjanartic f286638b19 Improve video access security by adding signed URL support from Bunny.net
Implements signed URLs for secure video and thumbnail delivery using the BUNNY_SECURITY_KEY environment variable in bunny.ts.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 50814a1e-92e4-4968-856f-7bc7eedf5e8f
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/50814a1e-92e4-4968-856f-7bc7eedf5e8f/3tWpY1N
2025-08-04 19:13:59 +00:00

143 lines
4.5 KiB
TypeScript

import { type Video, type InsertVideo } from "@shared/schema";
import crypto from 'crypto';
interface BunnyVideo {
guid: string;
title: string;
length: number;
status: number;
dateUploaded: string;
views: number;
thumbnailFileName?: string;
category?: string;
}
interface BunnyLibraryResponse {
items: BunnyVideo[];
currentPage: number;
itemsPerPage: number;
totalItems: number;
}
export class BunnyService {
private apiKey: string;
private libraryId: string;
private hostname: string;
private securityKey: string;
constructor() {
this.apiKey = process.env.BUNNY_API_KEY!;
this.libraryId = process.env.BUNNY_LIBRARY_ID!;
this.hostname = process.env.BUNNY_HOSTNAME!;
this.securityKey = process.env.BUNNY_SECURITY_KEY || ''; // CDN security key for signing URLs
if (!this.apiKey || !this.libraryId || !this.hostname) {
throw new Error("Missing Bunny.net configuration");
}
}
private async makeRequest(endpoint: string): Promise<any> {
const url = `https://video.bunnycdn.com/library/${this.libraryId}/${endpoint}`;
const response = await fetch(url, {
headers: {
'AccessKey': this.apiKey,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Bunny API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
private generateSignedUrl(path: string, expiryHours: number = 1): string {
if (!this.securityKey) {
// If no security key, return iframe embed as fallback
const videoId = path.split('/')[1];
return `https://iframe.mediadelivery.net/embed/${this.libraryId}/${videoId}?controls=true&autoplay=false`;
}
const expireTimestamp = Math.floor(Date.now() / 1000) + (expiryHours * 3600);
const tokenContent = `${this.securityKey}${path}${expireTimestamp}`;
const hash = crypto.createHash('md5').update(tokenContent).digest();
const token = Buffer.from(hash).toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `https://${this.hostname}${path}?token=${token}&expires=${expireTimestamp}`;
}
private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video {
// Generate signed URLs for private video access
const videoPath = `/${bunnyVideo.guid}/playlist.m3u8`;
const thumbnailPath = `/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName || 'thumbnail.jpg'}`;
const videoUrl = this.generateSignedUrl(videoPath);
const thumbnailUrl = this.generateSignedUrl(thumbnailPath);
return {
id: bunnyVideo.guid,
title: bunnyVideo.title || 'Untitled Video',
description: null, // Bunny API doesn't return description in list view
thumbnailUrl,
videoUrl,
duration: Math.floor(bunnyVideo.length || 0),
views: bunnyVideo.views || 0,
category: bunnyVideo.category || null,
createdAt: new Date(bunnyVideo.dateUploaded)
};
}
async getVideos(page: number = 1, itemsPerPage: number = 20, search?: string): Promise<{ videos: Video[], total: number }> {
try {
let endpoint = `videos?page=${page}&itemsPerPage=${itemsPerPage}&orderBy=date`;
if (search) {
endpoint += `&search=${encodeURIComponent(search)}`;
}
const response: BunnyLibraryResponse = await this.makeRequest(endpoint);
// Filter only successfully processed videos (status 4 = finished)
const processedVideos = response.items.filter(video => video.status === 4);
const videos = processedVideos.map(video => this.bunnyVideoToVideo(video));
return {
videos,
total: response.totalItems
};
} catch (error) {
console.error('Error fetching videos from Bunny:', error);
throw error;
}
}
async getVideo(guid: string): Promise<Video | null> {
try {
const bunnyVideo: BunnyVideo = await this.makeRequest(`videos/${guid}`);
return this.bunnyVideoToVideo(bunnyVideo);
} catch (error) {
console.error(`Error fetching video ${guid} from Bunny:`, error);
return null;
}
}
async getCategories(): Promise<string[]> {
try {
// Get all videos and extract unique categories
const { videos } = await this.getVideos(1, 1000);
const categories = Array.from(new Set(videos.map(v => v.category).filter(Boolean) as string[]));
return categories;
} catch (error) {
console.error('Error fetching categories from Bunny:', error);
return [];
}
}
}