Sync current dev state
Some checks failed
EasyStream Test Suite / test (pull_request) Has been cancelled
EasyStream Test Suite / code-quality (pull_request) Has been cancelled
EasyStream Test Suite / integration-test (pull_request) Has been cancelled

This commit is contained in:
SamiAhmed7777
2025-12-15 17:28:21 -08:00
parent 3bf64b1058
commit f0f346deb9
54 changed files with 11060 additions and 484 deletions

View File

@@ -0,0 +1,698 @@
/**
* EasyStream API Helper
* Modern fetch-based API client with JWT token management
*/
class EasyStreamAPI {
constructor(baseURL = '/api') {
this.baseURL = baseURL;
this.token = this.getStoredToken();
this.tokenExpiry = this.getStoredTokenExpiry();
}
/**
* Store JWT token in localStorage
* @param {string} token JWT token
* @param {number} expiresIn Expiry time in seconds
*/
setToken(token, expiresIn = 86400) {
this.token = token;
this.tokenExpiry = Date.now() + (expiresIn * 1000);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('jwt_token', token);
localStorage.setItem('jwt_expiry', this.tokenExpiry.toString());
}
}
/**
* Get stored token from localStorage
* @returns {string|null}
*/
getStoredToken() {
if (typeof localStorage === 'undefined') return null;
const expiry = localStorage.getItem('jwt_expiry');
if (expiry && Date.now() > parseInt(expiry)) {
// Token expired, clear it
this.clearToken();
return null;
}
return localStorage.getItem('jwt_token');
}
/**
* Get stored token expiry
* @returns {number|null}
*/
getStoredTokenExpiry() {
if (typeof localStorage === 'undefined') return null;
const expiry = localStorage.getItem('jwt_expiry');
return expiry ? parseInt(expiry) : null;
}
/**
* Clear stored token
*/
clearToken() {
this.token = null;
this.tokenExpiry = null;
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('jwt_token');
localStorage.removeItem('jwt_expiry');
}
}
/**
* Check if user is authenticated
* @returns {boolean}
*/
isAuthenticated() {
if (!this.token) return false;
if (this.tokenExpiry && Date.now() > this.tokenExpiry) {
this.clearToken();
return false;
}
return true;
}
/**
* Get authorization headers
* @returns {Object}
*/
getAuthHeaders() {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
/**
* Make API request
* @param {string} endpoint API endpoint
* @param {Object} options Fetch options
* @returns {Promise<Object>}
*/
async request(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
const config = {
...options,
headers: {
...this.getAuthHeaders(),
...options.headers
}
};
try {
const response = await fetch(url, config);
// Handle different response types
const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
// Handle authentication errors
if (response.status === 401) {
this.clearToken();
throw new Error(data.message || 'Authentication failed');
}
throw new Error(data.message || `Request failed with status ${response.status}`);
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* GET request
* @param {string} endpoint API endpoint
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, {
method: 'GET'
});
}
/**
* POST request
* @param {string} endpoint API endpoint
* @param {Object} data Request body
* @returns {Promise<Object>}
*/
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
/**
* PUT request
* @param {string} endpoint API endpoint
* @param {Object} data Request body
* @returns {Promise<Object>}
*/
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
/**
* DELETE request
* @param {string} endpoint API endpoint
* @returns {Promise<Object>}
*/
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE'
});
}
// ===== Authentication Methods =====
/**
* Login with JWT token
* @param {string} identifier Username or email
* @param {string} password Password
* @param {number} expiresIn Optional token expiry in seconds
* @returns {Promise<Object>}
*/
async login(identifier, password, expiresIn = null) {
try {
const data = await this.post('/auth.php?action=login_token', {
identifier,
password,
expires_in: expiresIn
});
if (data.success && data.token) {
this.setToken(data.token, data.expires_in || 86400);
}
return data;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
}
/**
* Logout and clear token
* @returns {Promise<Object>}
*/
async logout() {
try {
// Optionally call backend logout endpoint
// (though with JWT, client-side token removal is usually sufficient)
const data = await this.post('/auth.php?action=logout', {});
this.clearToken();
return data;
} catch (error) {
// Clear token even if API call fails
this.clearToken();
throw error;
}
}
/**
* Get current user info
* @returns {Promise<Object>}
*/
async getCurrentUser() {
if (!this.isAuthenticated()) {
throw new Error('Not authenticated');
}
return this.get('/auth.php?action=verify_token');
}
/**
* Verify token validity
* @returns {Promise<boolean>}
*/
async verifyToken() {
if (!this.token) return false;
try {
const data = await this.get('/auth.php?action=verify_token');
return data.success && data.valid;
} catch (error) {
this.clearToken();
return false;
}
}
// ===== Video API Methods =====
/**
* Get videos list
* @param {Object} params Query parameters (page, limit, sort, etc.)
* @returns {Promise<Object>}
*/
async getVideos(params = {}) {
return this.get('/videos.php', params);
}
/**
* Get video by ID
* @param {string} videoId Video ID
* @returns {Promise<Object>}
*/
async getVideo(videoId) {
return this.get(`/videos.php?id=${videoId}`);
}
/**
* Upload video metadata
* @param {Object} videoData Video metadata
* @returns {Promise<Object>}
*/
async createVideo(videoData) {
return this.post('/videos.php', videoData);
}
/**
* Update video
* @param {string} videoId Video ID
* @param {Object} videoData Updated video data
* @returns {Promise<Object>}
*/
async updateVideo(videoId, videoData) {
return this.put(`/videos.php?id=${videoId}`, videoData);
}
/**
* Delete video
* @param {string} videoId Video ID
* @returns {Promise<Object>}
*/
async deleteVideo(videoId) {
return this.delete(`/videos.php?id=${videoId}`);
}
/**
* Search videos
* @param {string} query Search query
* @param {Object} params Additional parameters
* @returns {Promise<Object>}
*/
async searchVideos(query, params = {}) {
return this.get('/videos.php', { action: 'search', q: query, ...params });
}
/**
* Like/unlike video
* @param {string} fileKey Video file key
* @param {string} likeType 'like' or 'dislike'
* @returns {Promise<Object>}
*/
async likeVideo(fileKey, likeType = 'like') {
return this.post('/videos.php?action=like', { file_key: fileKey, like_type: likeType });
}
/**
* Increment video view count
* @param {string} fileKey Video file key
* @returns {Promise<Object>}
*/
async recordVideoView(fileKey) {
return this.post('/videos.php?action=view', { file_key: fileKey });
}
/**
* Add/remove video from watch later
* @param {string} fileKey Video file key
* @returns {Promise<Object>}
*/
async toggleWatchLater(fileKey) {
return this.post('/videos.php?action=watch_later', { file_key: fileKey });
}
// ===== User API Methods =====
/**
* Get user profile
* @param {string} userId User ID (optional, defaults to current user)
* @returns {Promise<Object>}
*/
async getUserProfile(userId = null) {
if (userId) {
return this.get(`/user.php?id=${userId}`);
}
return this.get('/user.php?action=profile');
}
/**
* Get current user's profile (alias)
* @returns {Promise<Object>}
*/
async getMyProfile() {
return this.get('/user.php?action=me');
}
/**
* Update user profile
* @param {Object} userData Updated user data
* @returns {Promise<Object>}
*/
async updateProfile(userData) {
return this.put('/user.php', userData);
}
/**
* Upload user avatar
* @param {File} file Avatar file
* @returns {Promise<Object>}
*/
async uploadAvatar(file) {
const formData = new FormData();
formData.append('avatar', file);
return this.request('/user.php?action=avatar', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
},
body: formData
});
}
/**
* Get user statistics
* @returns {Promise<Object>}
*/
async getUserStats() {
return this.get('/user.php?action=stats');
}
/**
* Get user's videos
* @param {number} userId User ID (optional)
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async getUserVideos(userId = null, params = {}) {
const queryParams = userId ? { ...params, id: userId } : params;
return this.get('/user.php?action=videos', queryParams);
}
/**
* Get user's subscriptions
* @returns {Promise<Object>}
*/
async getUserSubscriptions() {
return this.get('/user.php?action=subscriptions');
}
/**
* Get user's subscribers
* @param {number} userId User ID (optional)
* @returns {Promise<Object>}
*/
async getUserSubscribers(userId = null) {
const params = userId ? { id: userId } : {};
return this.get('/user.php?action=subscribers', params);
}
// ===== Comments API Methods =====
/**
* Get comments for a video
* @param {string} fileKey Video file key
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async getComments(fileKey, params = {}) {
return this.get('/comments.php', { file_key: fileKey, ...params });
}
/**
* Get single comment with replies
* @param {number} commentId Comment ID
* @returns {Promise<Object>}
*/
async getComment(commentId) {
return this.get(`/comments.php?id=${commentId}`);
}
/**
* Create new comment
* @param {string} fileKey Video file key
* @param {string} commentText Comment text
* @param {number} parentId Parent comment ID (for replies)
* @returns {Promise<Object>}
*/
async createComment(fileKey, commentText, parentId = null) {
return this.post('/comments.php?action=create', {
file_key: fileKey,
comment_text: commentText,
parent_id: parentId
});
}
/**
* Update comment
* @param {number} commentId Comment ID
* @param {string} commentText Updated text
* @returns {Promise<Object>}
*/
async updateComment(commentId, commentText) {
return this.put(`/comments.php?id=${commentId}`, {
comment_text: commentText
});
}
/**
* Delete comment
* @param {number} commentId Comment ID
* @returns {Promise<Object>}
*/
async deleteComment(commentId) {
return this.delete(`/comments.php?id=${commentId}`);
}
/**
* Like/unlike comment
* @param {number} commentId Comment ID
* @returns {Promise<Object>}
*/
async likeComment(commentId) {
return this.post('/comments.php?action=like', { comment_id: commentId });
}
/**
* Report comment
* @param {number} commentId Comment ID
* @param {string} reason Report reason
* @returns {Promise<Object>}
*/
async reportComment(commentId, reason) {
return this.post('/comments.php?action=report', {
comment_id: commentId,
reason: reason
});
}
// ===== Subscriptions API Methods =====
/**
* Get user's subscriptions
* @returns {Promise<Object>}
*/
async getSubscriptions() {
return this.get('/subscriptions.php?action=list');
}
/**
* Get channel's subscribers
* @param {number} channelId Channel ID
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async getSubscribers(channelId, params = {}) {
return this.get('/subscriptions.php?action=subscribers', {
channel_id: channelId,
...params
});
}
/**
* Get subscription feed (videos from subscribed channels)
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async getSubscriptionFeed(params = {}) {
return this.get('/subscriptions.php?action=feed', params);
}
/**
* Check if subscribed to a channel
* @param {number} channelId Channel ID
* @returns {Promise<Object>}
*/
async checkSubscription(channelId) {
return this.get('/subscriptions.php?action=check', { channel_id: channelId });
}
/**
* Subscribe to a channel
* @param {number} channelId Channel ID
* @returns {Promise<Object>}
*/
async subscribe(channelId) {
return this.post('/subscriptions.php', { channel_id: channelId });
}
/**
* Unsubscribe from a channel
* @param {number} channelId Channel ID
* @returns {Promise<Object>}
*/
async unsubscribe(channelId) {
return this.delete(`/subscriptions.php?channel_id=${channelId}`);
}
// ===== Social API Methods =====
/**
* Get social feed
* @param {Object} params Query parameters
* @returns {Promise<Object>}
*/
async getSocialFeed(params = {}) {
return this.get('/social.php', params);
}
// ===== Helper Methods =====
/**
* Upload file with progress tracking
* @param {string} endpoint Upload endpoint
* @param {File} file File to upload
* @param {Object} additionalData Additional form data
* @param {Function} onProgress Progress callback
* @returns {Promise<Object>}
*/
async uploadFile(endpoint, file, additionalData = {}, onProgress = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
// Add additional data
Object.keys(additionalData).forEach(key => {
formData.append(key, additionalData[key]);
});
// Progress tracking
if (onProgress && xhr.upload) {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
onProgress(percentComplete, e.loaded, e.total);
}
});
}
// Handle completion
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (e) {
resolve({ success: true, data: xhr.responseText });
}
} else {
try {
const error = JSON.parse(xhr.responseText);
reject(new Error(error.message || `Upload failed with status ${xhr.status}`));
} catch (e) {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
}
});
// Handle errors
xhr.addEventListener('error', () => {
reject(new Error('Upload failed due to network error'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'));
});
// Add auth header
if (this.token) {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
}
// Send request
const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
xhr.open('POST', url);
xhr.send(formData);
});
}
/**
* Handle API errors consistently
* @param {Error} error Error object
* @param {Function} callback Optional callback
*/
handleError(error, callback = null) {
console.error('API Error:', error);
// Show user-friendly error message
const message = error.message || 'An unexpected error occurred';
if (callback) {
callback(message);
} else if (typeof window !== 'undefined' && window.showNotification) {
window.showNotification('error', message);
} else {
alert(message);
}
return { success: false, error: message };
}
}
// Create global instance
if (typeof window !== 'undefined') {
window.EasyStreamAPI = EasyStreamAPI;
window.api = new EasyStreamAPI();
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = EasyStreamAPI;
}

View File

@@ -0,0 +1,183 @@
<?php
/*
* EasyStream JW Player Branding Migration
* Safely updates serialized JW Player config in `db_fileplayers` to remove legacy ViewShark branding.
* Usage: php f_scripts/migrations/update_jw_branding.php
*/
define('_ISVALID', true);
// Bootstrap core to reuse DB + config
require_once __DIR__ . '/../../f_core/config.core.php';
/**
* Unserialize safely (no objects), returning array or null
*/
function safe_unserialize($str)
{
if (!is_string($str) || $str === '') {
return null;
}
// PHP >= 7 allows allowed_classes => false
$data = @unserialize($str, ['allowed_classes' => false]);
if ($data === false || !is_array($data)) {
// Try without options for older compatibility
$data = @unserialize($str);
if (!is_array($data)) {
// Some environments may deliver escaped content from DB; try un-escaping
$clean = stripslashes($str);
$data = @unserialize($clean, ['allowed_classes' => false]);
if ($data === false || !is_array($data)) {
$data = @unserialize($clean);
if (!is_array($data)) {
// Last resort: stripcslashes
$clean2 = stripcslashes($str);
$data = @unserialize($clean2, ['allowed_classes' => false]);
if ($data === false || !is_array($data)) {
$data = @unserialize($clean2);
if (!is_array($data)) {
return null;
}
}
}
}
}
}
return $data;
}
/**
* Apply branding changes to JW config array
*/
function apply_branding(array $cfg, string $siteName, string $siteUrl): array
{
$changed = false;
$replacements = [
// Remove external logo file by default; admin can set later from UI
'jw_logo_file' => '',
// Point links to local site if available
'jw_logo_link' => $siteUrl,
// Update right-click text/link
'jw_rc_text' => 'Powered by ' . ($siteName ?: 'EasyStream'),
'jw_rc_link' => $siteUrl,
];
foreach ($replacements as $key => $value) {
if (array_key_exists($key, $cfg) && $cfg[$key] !== $value) {
$cfg[$key] = $value;
$changed = true;
}
}
// If jw_share_enabled is set, ensure no external domains are hardcoded in share link defaults
if (isset($cfg['jw_share_link']) && is_string($cfg['jw_share_link'])) {
// Replace known legacy domains if present
$legacy = ['viewsharkdemo.com', 'viewshark.com'];
foreach ($legacy as $dom) {
if (stripos($cfg['jw_share_link'], $dom) !== false) {
$cfg['jw_share_link'] = '';
$changed = true;
break;
}
}
}
$cfg['__changed__'] = $changed; // marker for caller
return $cfg;
}
try {
global $db, $cfg; // from config.core.php
if (!isset($db)) {
throw new Exception('Database connection not initialized.');
}
$siteName = $cfg['site_name'] ?? 'EasyStream';
$siteUrl = $cfg['site_url'] ?? '';
$targets = ['jw_local', 'jw_embed'];
$updated = 0;
foreach ($targets as $name) {
$rs = $db->Execute("SELECT `db_id`, `db_config` FROM `db_fileplayers` WHERE `db_name` = ? LIMIT 1", [$name]);
if ($rs && !$rs->EOF) {
$dbId = (int)$rs->fields['db_id'];
$raw = $rs->fields['db_config'];
$arr = safe_unserialize($raw);
if (is_array($arr)) {
$arr = apply_branding($arr, $siteName, $siteUrl);
$changed = !empty($arr['__changed__']);
unset($arr['__changed__']);
if ($changed) {
$serialized = serialize($arr);
$ok = $db->Execute("UPDATE `db_fileplayers` SET `db_config` = ? WHERE `db_id` = ?", [$serialized, $dbId]);
if ($ok) {
echo "Updated branding for {$name} (db_id={$dbId})\n";
$updated++;
} else {
echo "Failed to update {$name} (db_id={$dbId}): " . $db->ErrorMsg() . "\n";
}
} else {
echo "No changes needed for {$name}\n";
}
} else {
// Fallback: attempt in-place serialized string rewriting for known keys
$orig = (string)$raw;
$updatedSerialized = $orig;
$kv = [
'jw_logo_file' => '',
'jw_logo_link' => $siteUrl,
'jw_rc_text' => 'Powered by ' . ($siteName ?: 'EasyStream'),
'jw_rc_link' => $siteUrl,
];
$didChange = false;
foreach ($kv as $k => $v) {
$escapedKey = preg_quote($k, '/');
$replacement = function ($m) use ($v) {
$len = strlen($v);
$safe = str_replace('"', '"', $v); // value is plain; ensure quotes are safe
return $m[1] . 's:' . $len . ':"' . $safe . '";';
};
$pattern = '/(s:\d+:"' . $escapedKey . '";s:)\d+:("[\s\S]*?");/';
$new = preg_replace_callback($pattern, $replacement, $updatedSerialized, 1, $count);
if ($count > 0 && is_string($new)) {
$updatedSerialized = $new;
$didChange = true;
}
}
if ($didChange) {
$ok = $db->Execute("UPDATE `db_fileplayers` SET `db_config` = ? WHERE `db_id` = ?", [$updatedSerialized, $dbId]);
if ($ok) {
echo "Updated branding (fallback) for {$name} (db_id={$dbId})\n";
$updated++;
} else {
echo "Failed fallback update {$name} (db_id={$dbId}): " . $db->ErrorMsg() . "\n";
}
} else {
$snippet = substr((string)$raw, 0, 160);
echo "Could not unserialize config for {$name}; snippet: " . str_replace(["\n","\r"], ['\\n',''], $snippet) . "\n";
}
}
} else {
echo "No db_fileplayers row found for {$name}; skipping.\n";
}
}
echo "Done. Updated {$updated} row(s).\n";
exit(0);
} catch (Throwable $e) {
// Best-effort logging
if (class_exists('VLogger')) {
$logger = VLogger::getInstance();
$logger->log(VLogger::ERROR, 'JW branding migration failed', ['error' => $e->getMessage()]);
}
fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n");
exit(1);
}