['video/mp4', 'video/webm', 'video/ogg', 'video/x-matroska', 'video/quicktime', 'application/x-mpegURL', 'video/mp2t'], 'short' => ['video/mp4', 'video/webm', 'video/ogg', 'video/x-matroska', 'video/quicktime'], 'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'], 'audio' => ['audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/wav', 'audio/ogg', 'audio/flac'], 'document' => [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain' ], 'live' => ['video/mp2t', 'application/octet-stream', 'application/x-mpegURL'], 'blog' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] ]; /** @var array */ private $defaultMaxSizes = [ 'video' => 1024 * 1024 * 1024, // 1GB 'short' => 500 * 1024 * 1024, // 500MB 'image' => 50 * 1024 * 1024, // 50MB 'audio' => 300 * 1024 * 1024, // 300MB 'document' => 100 * 1024 * 1024, // 100MB 'live' => 2048 * 1024 * 1024, // 2GB 'blog' => 25 * 1024 * 1024 // 25MB ]; /** * Singleton accessor * @return VContent */ public static function getInstance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Constructor */ private function __construct() { global $cfg, $class_database; $this->cfg = $cfg; $this->db = $class_database ?: new VDatabase(); $this->logger = VLogger::getInstance(); $this->security = VSecurity::getInstance(); $this->redis = $this->connectRedis(); $baseUploadDir = $this->cfg['upload_files_dir'] ?? ($this->cfg['main_dir'] . '/f_data/data_userfiles/user_uploads'); $this->progressDir = $baseUploadDir . '/progress-meta'; $this->ensureDirectory($baseUploadDir); $this->ensureDirectory($this->progressDir); $this->configureNativeProgressTracking(); } /** * Handle an uploaded file with validation and storage * @param array $file * @param int $userId * @param array $options * @return array */ public function handleUpload(array $file, $userId, array $options = []) { $userId = (int) $userId; if ($userId <= 0) { return ['success' => false, 'message' => 'Invalid user ID']; } $options = array_merge([ 'type' => 'video', 'allowed_mime' => null, 'max_size' => null, 'scan_for_malware' => true, 'storage_dir' => null, 'progress_id' => null, 'store_metadata' => true, 'rate_limit_key' => null ], $options); $type = $this->normalizeType($options['type']); if (!$this->isUploadEnabled($type)) { return ['success' => false, 'message' => ucfirst($type) . ' uploads are currently disabled']; } $allowedMime = $options['allowed_mime'] ?: $this->defaultAllowedTypes[$type]; $maxSize = $options['max_size'] ?: $this->getMaxSizeForType($type); $rateLimitKey = $options['rate_limit_key'] ?: 'upload_user_' . $userId; if (!VSecurity::checkRateLimit($rateLimitKey, 10, 300, 'content_upload')) { return ['success' => false, 'message' => 'Upload rate limit exceeded. Please wait before uploading again.']; } $validation = $this->validateUpload($file, $allowedMime, $maxSize, $options['scan_for_malware']); if (!$validation['valid']) { $this->logger->warning('Upload validation failed', [ 'user_id' => $userId, 'type' => $type, 'error' => $validation['error'] ?? 'unknown', 'file_name' => $file['name'] ?? null, 'file_size' => $file['size'] ?? null ]); return ['success' => false, 'message' => $validation['error'] ?? 'Upload validation failed']; } $storageDir = $options['storage_dir'] ?: $this->getStorageDirectory($type, $userId); $this->ensureDirectory($storageDir); $uploadId = $options['progress_id'] ?: $this->generateUploadId($userId); $this->initProgress($uploadId, (int) ($file['size'] ?? 0)); $storedName = $this->generateStoredFilename($file['name'] ?? 'upload', $type); $destination = rtrim($storageDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $storedName; if (!$this->moveUploadedFile($file['tmp_name'], $destination)) { $this->failProgress($uploadId, 'Unable to move uploaded file'); $this->logger->error('Failed to move uploaded file', [ 'user_id' => $userId, 'type' => $type, 'tmp_name' => $file['tmp_name'] ?? null, 'target' => $destination ]); return ['success' => false, 'message' => 'Failed to store uploaded file']; } $hash = hash_file('sha256', $destination); $fileSize = filesize($destination); $mimeType = $validation['mime_type'] ?? mime_content_type($destination); $relative = $this->relativeToUploadBase($destination); $fileUrl = $this->buildFileUrl($relative); $this->completeProgress($uploadId); $this->logger->info('File uploaded successfully', [ 'user_id' => $userId, 'type' => $type, 'file_name' => $storedName, 'file_size' => $fileSize, 'hash' => $hash, 'upload_id' => $uploadId ]); if (!empty($options['store_metadata'])) { $this->persistUploadMetadata($userId, $type, [ 'stored_name' => $storedName, 'stored_path' => $relative, 'mime_type' => $mimeType, 'hash' => $hash, 'size' => $fileSize, 'original_name' => $file['name'] ?? $storedName, 'upload_id' => $uploadId ]); } return [ 'success' => true, 'message' => 'File uploaded successfully', 'data' => [ 'upload_id' => $uploadId, 'type' => $type, 'stored_name' => $storedName, 'stored_path' => $relative, 'file_url' => $fileUrl, 'mime_type' => $mimeType, 'hash' => $hash, 'size' => $fileSize ] ]; } /** * Get upload progress information * @param string $uploadId * @return array */ public function getUploadProgress($uploadId) { if (!$uploadId) { return ['status' => 'unknown', 'progress' => 0]; } if (session_status() === PHP_SESSION_NONE) { session_start(); } $sessionKey = $this->progressPrefix . $uploadId; if (isset($_SESSION[$sessionKey]) && is_array($_SESSION[$sessionKey])) { $info = $_SESSION[$sessionKey]; $progress = 0; if (!empty($info['bytes_total'])) { $progress = (int) round(($info['bytes_processed'] / $info['bytes_total']) * 100); } return [ 'status' => ($progress >= 100) ? 'completed' : 'in_progress', 'progress' => min(100, max(0, $progress)), 'bytes_processed' => (int) ($info['bytes_processed'] ?? 0), 'bytes_total' => (int) ($info['bytes_total'] ?? 0), 'started_at' => $info['start_time'] ?? null, 'updated_at' => gmdate('c') ]; } // Redis cache fallback if ($this->redis instanceof Redis) { try { $raw = $this->redis->get($this->progressPrefix . $uploadId); if ($raw) { $decoded = json_decode($raw, true); if (is_array($decoded)) { return $decoded; } } } catch (Exception $e) { $this->logger->warning('Failed to fetch upload progress from Redis', [ 'upload_id' => $uploadId, 'error' => $e->getMessage() ]); } } // File-based fallback $fileData = $this->readProgressFile($uploadId); if ($fileData) { return $fileData; } return ['status' => 'unknown', 'progress' => 0]; } /** * Initialise upload progress metadata * @param string $uploadId * @param int $totalBytes */ public function initProgress($uploadId, $totalBytes = 0) { $data = [ 'status' => 'in_progress', 'progress' => 0, 'bytes_processed' => 0, 'bytes_total' => (int) $totalBytes, 'upload_id' => $uploadId, 'started_at' => gmdate('c'), 'updated_at' => gmdate('c') ]; $this->writeProgress($uploadId, $data); } /** * Update progress checkpoint * @param string $uploadId * @param int $bytesProcessed * @param int $totalBytes */ public function updateProgress($uploadId, $bytesProcessed, $totalBytes) { $totalBytes = max(0, (int) $totalBytes); $bytesProcessed = max(0, (int) $bytesProcessed); $progress = ($totalBytes > 0) ? (int) round(($bytesProcessed / $totalBytes) * 100) : 0; $data = [ 'status' => ($progress >= 100) ? 'completed' : 'in_progress', 'progress' => min(100, $progress), 'bytes_processed' => $bytesProcessed, 'bytes_total' => $totalBytes, 'upload_id' => $uploadId, 'started_at' => null, 'updated_at' => gmdate('c') ]; $this->writeProgress($uploadId, $data); } /** * Mark upload as completed * @param string $uploadId */ public function completeProgress($uploadId) { $data = [ 'status' => 'completed', 'progress' => 100, 'bytes_processed' => null, 'bytes_total' => null, 'upload_id' => $uploadId, 'started_at' => null, 'updated_at' => gmdate('c') ]; $this->writeProgress($uploadId, $data); } /** * Mark upload as failed with message * @param string $uploadId * @param string $message */ public function failProgress($uploadId, $message) { $data = [ 'status' => 'failed', 'progress' => 0, 'bytes_processed' => null, 'bytes_total' => null, 'upload_id' => $uploadId, 'message' => $message, 'updated_at' => gmdate('c') ]; $this->writeProgress($uploadId, $data); } /** * Remove stored progress metadata * @param string $uploadId */ public function cleanupProgress($uploadId) { $path = $this->getProgressPath($uploadId); if (is_file($path)) { @unlink($path); } if ($this->redis instanceof Redis) { try { $this->redis->del($this->progressPrefix . $uploadId); } catch (Exception $e) { $this->logger->warning('Failed to delete upload progress key from Redis', [ 'upload_id' => $uploadId, 'error' => $e->getMessage() ]); } } } /** * Get the list of allowed MIME types * @param string|null $type * @return array */ public function getAllowedMimeTypes($type = null) { if ($type === null) { return $this->defaultAllowedTypes; } $type = $this->normalizeType($type); return $this->defaultAllowedTypes[$type] ?? []; } /** * Get maximum size in bytes * @param string|null $type * @return int|array */ public function getMaxUploadSize($type = null) { if ($type === null) { $sizes = []; foreach (array_keys($this->defaultAllowedTypes) as $t) { $sizes[$t] = $this->getMaxSizeForType($t); } return $sizes; } return $this->getMaxSizeForType($type); } /** * Connect to Redis if available * @return Redis|null */ private function connectRedis() { try { if (!class_exists('Redis')) { return null; } $redis = new Redis(); $host = getenv('REDIS_HOST') ?: 'redis'; $port = (int) (getenv('REDIS_PORT') ?: 6379); $db = (int) (getenv('REDIS_DB') ?: 0); $timeout = (float) (getenv('REDIS_TIMEOUT') ?: 2.0); if (!$redis->connect($host, $port, $timeout)) { return null; } $password = getenv('REDIS_PASSWORD'); if (!empty($password)) { $redis->auth($password); } if ($db > 0) { $redis->select($db); } return $redis; } catch (Exception $e) { $this->logger->warning('Redis connection failed for VContent', ['error' => $e->getMessage()]); return null; } } /** * Configure PHP native upload progress tracking */ private function configureNativeProgressTracking() { ini_set('session.upload_progress.enabled', '1'); ini_set('session.upload_progress.cleanup', '1'); ini_set('session.upload_progress.name', 'UPLOAD_IDENTIFIER'); ini_set('session.upload_progress.prefix', $this->progressPrefix); } /** * Validate upload with fallback for testing environments * @param array $file * @param array $allowedMime * @param int $maxSize * @param bool $scanForMalware * @return array */ private function validateUpload(array $file, array $allowedMime, $maxSize, $scanForMalware) { $validation = VSecurity::validateFileUploadAdvanced($file, $allowedMime, $maxSize, $scanForMalware); if (!$validation['valid'] && defined('TESTING') && TESTING === true) { // Allow CLI tests to bypass is_uploaded_file restriction if (($validation['error'] ?? '') === 'No file uploaded or invalid upload') { $validation = $this->validateUploadForTests($file, $allowedMime, $maxSize, $scanForMalware); } } return $validation; } /** * Validation helper used during automated tests */ private function validateUploadForTests(array $file, array $allowedMime, $maxSize, $scanForMalware) { $tmpName = $file['tmp_name'] ?? null; if (!$tmpName || !is_file($tmpName)) { return ['valid' => false, 'error' => 'Testing upload: temporary file missing']; } if (($file['size'] ?? 0) > $maxSize) { return ['valid' => false, 'error' => 'File too large']; } $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $tmpName); finfo_close($finfo); if (!empty($allowedMime) && !in_array($mimeType, $allowedMime)) { return ['valid' => false, 'error' => 'Invalid file type']; } if ($scanForMalware) { $content = file_get_contents($tmpName, false, null, 0, 8192); $patterns = [ '/<\?php/i', '/