mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
2b68744dd5
- Resolved issues with panel tabs redirecting to the dashboard on 401 errors by allowing browser requests without Bearer tokens to fall through to panel handlers. - Extended the rate limit whitelist for Devices/Users API endpoints to prevent 429 errors during high load. - Introduced a new rate limiter for desktop layout preference saves, ensuring session-authenticated writes are managed effectively. - Implemented staggered loading for API requests on the Devices page to optimize performance and reduce rate limit bursts. - Updated relevant tests to cover new fallthrough behavior and rate limiting logic.
294 lines
9.3 KiB
JavaScript
294 lines
9.3 KiB
JavaScript
/**
|
|
* BetterDesk Console - Utility Functions
|
|
*/
|
|
|
|
const Utils = {
|
|
/**
|
|
* Format date to locale string
|
|
*/
|
|
formatDate(dateStr, options = {}) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return '-';
|
|
|
|
const defaultOptions = {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
};
|
|
|
|
return date.toLocaleDateString(window.BetterDesk.lang, { ...defaultOptions, ...options });
|
|
},
|
|
|
|
/**
|
|
* Format relative time (e.g., "5 minutes ago")
|
|
*/
|
|
formatRelativeTime(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return '-';
|
|
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (seconds < 60) return _('time.just_now');
|
|
if (minutes < 60) return _('time.minutes_ago', { count: minutes });
|
|
if (hours < 24) return _('time.hours_ago', { count: hours });
|
|
if (days < 7) return _('time.days_ago', { count: days });
|
|
|
|
return Utils.formatDate(dateStr);
|
|
},
|
|
|
|
/**
|
|
* Debounce function
|
|
*/
|
|
debounce(fn, delay = 300) {
|
|
let timeoutId;
|
|
return function(...args) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Throttle function
|
|
*/
|
|
throttle(fn, limit = 300) {
|
|
let inThrottle;
|
|
return function(...args) {
|
|
if (!inThrottle) {
|
|
fn.apply(this, args);
|
|
inThrottle = true;
|
|
setTimeout(() => inThrottle = false, limit);
|
|
}
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Copy text to clipboard
|
|
*/
|
|
async copyToClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch (err) {
|
|
// Fallback for older browsers
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
return true;
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
/**
|
|
* Sanitize color value to prevent XSS
|
|
* Only allows valid hex colors (#RGB, #RRGGBB) and named colors
|
|
*/
|
|
sanitizeColor(color) {
|
|
if (!color || typeof color !== 'string') return '#808080';
|
|
// Allow hex colors
|
|
if (/^#[0-9A-Fa-f]{3}$/.test(color) || /^#[0-9A-Fa-f]{6}$/.test(color)) {
|
|
return color;
|
|
}
|
|
// Allow safe named colors
|
|
const safeColors = ['red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink',
|
|
'cyan', 'magenta', 'brown', 'gray', 'grey', 'black', 'white',
|
|
'lime', 'teal', 'navy', 'maroon', 'olive', 'aqua', 'silver'];
|
|
if (safeColors.includes(color.toLowerCase())) {
|
|
return color;
|
|
}
|
|
return '#808080'; // Default gray
|
|
},
|
|
|
|
/**
|
|
* Parse URL query parameters
|
|
*/
|
|
getQueryParams() {
|
|
return Object.fromEntries(new URLSearchParams(window.location.search));
|
|
},
|
|
|
|
/**
|
|
* Update URL query parameter without reload
|
|
*/
|
|
setQueryParam(key, value) {
|
|
const url = new URL(window.location);
|
|
if (value === null || value === undefined) {
|
|
url.searchParams.delete(key);
|
|
} else {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
window.history.replaceState({}, '', url);
|
|
},
|
|
|
|
/**
|
|
* Generate a simple unique ID (using crypto.randomUUID when available)
|
|
*/
|
|
generateId() {
|
|
if (window.crypto && window.crypto.randomUUID) {
|
|
return 'id-' + crypto.randomUUID().split('-')[0];
|
|
}
|
|
// Fallback for older browsers
|
|
return 'id-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
|
},
|
|
|
|
/**
|
|
* Check if element is in viewport
|
|
*/
|
|
isInViewport(element) {
|
|
const rect = element.getBoundingClientRect();
|
|
return (
|
|
rect.top >= 0 &&
|
|
rect.left >= 0 &&
|
|
rect.bottom <= window.innerHeight &&
|
|
rect.right <= window.innerWidth
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Sleep/delay function
|
|
*/
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
},
|
|
|
|
/**
|
|
* Restrict client-side fetch targets to same-origin relative API paths.
|
|
*/
|
|
resolveApiEndpoint(endpoint) {
|
|
if (typeof endpoint !== 'string') {
|
|
throw new Error('Invalid API endpoint');
|
|
}
|
|
const trimmed = endpoint.trim();
|
|
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
|
|
throw new Error('Invalid API endpoint');
|
|
}
|
|
if (/[\0\\]/.test(trimmed)) {
|
|
throw new Error('Invalid API endpoint');
|
|
}
|
|
const parsed = new URL(trimmed, window.location.origin);
|
|
if (parsed.origin !== window.location.origin) {
|
|
throw new Error('Invalid API endpoint');
|
|
}
|
|
return parsed.pathname + parsed.search;
|
|
},
|
|
|
|
/**
|
|
* API request helper with error handling
|
|
*/
|
|
async api(endpoint, options = {}) {
|
|
const safeEndpoint = Utils.resolveApiEndpoint(endpoint);
|
|
const defaults = {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'same-origin'
|
|
};
|
|
|
|
// Add CSRF token if available
|
|
if (window.BetterDesk.csrfToken) {
|
|
defaults.headers['X-CSRF-Token'] = window.BetterDesk.csrfToken;
|
|
}
|
|
|
|
const config = {
|
|
...defaults,
|
|
...options,
|
|
headers: { ...defaults.headers, ...options.headers }
|
|
};
|
|
|
|
// Don't send body for GET/HEAD requests
|
|
if (['GET', 'HEAD'].includes(config.method?.toUpperCase())) {
|
|
delete config.body;
|
|
} else if (config.body && typeof config.body === 'object') {
|
|
config.body = JSON.stringify(config.body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(safeEndpoint, config);
|
|
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) {
|
|
const error = new Error(data.error || data.message || 'Request failed');
|
|
error.status = response.status;
|
|
error.data = data;
|
|
throw error;
|
|
}
|
|
|
|
// Automatically extract data field from standard API responses
|
|
// API format: { success: true, data: { ... } }
|
|
if (data && typeof data === 'object' && data.success === true && data.data !== undefined) {
|
|
return data.data;
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
if (error.status === 401) {
|
|
var path = window.location.pathname || '';
|
|
var panelUser = window.BetterDesk && window.BetterDesk.user;
|
|
if (path.startsWith('/remote') && !path.startsWith('/remote/login')) {
|
|
window.location.href = '/remote/login?return=' +
|
|
encodeURIComponent(path + (window.location.search || '')) + '&expired=1';
|
|
} else if (!panelUser) {
|
|
window.location.href = '/login';
|
|
} else if (typeof Notifications !== 'undefined' && Notifications.error) {
|
|
var msg = (typeof _ === 'function' ? _('auth.totp_session_expired') : null)
|
|
|| error.message
|
|
|| 'Unauthorized';
|
|
Notifications.error(msg);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get platform icon name based on platform string
|
|
*/
|
|
getPlatformIcon(platform) {
|
|
if (!platform) return 'devices';
|
|
const p = platform.toLowerCase();
|
|
if (p.includes('windows')) return 'desktop_windows';
|
|
if (p.includes('mac') || p.includes('darwin')) return 'desktop_mac';
|
|
if (p.includes('linux')) return 'computer';
|
|
if (p.includes('android')) return 'smartphone';
|
|
if (p.includes('ios') || p.includes('iphone')) return 'phone_iphone';
|
|
return 'devices';
|
|
}
|
|
};
|
|
|
|
// Export for modules or attach to window
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = Utils;
|
|
} else {
|
|
window.Utils = Utils;
|
|
}
|