mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
09d20c3444
Refactored the consent handling logic for both terminal and desktop sessions to utilize a unified requestRemoteConsent method. This change simplifies the consent process, ensuring consistent behavior across different session types. Additionally, introduced default session ID handling and improved logging for consent status. Updated related payload structures to include operator names for better tracking.
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/**
|
|
* Agent installer bundle service
|
|
*
|
|
* Responsible for:
|
|
* - validating + normalizing branding payloads supplied by operators
|
|
* in the "Generator Agenta" panel
|
|
* - hashing the normalized branding (Phase 2 build-artifact cache key)
|
|
* - generating short, URL-safe bundle IDs
|
|
*
|
|
* The build pipeline itself (Tauri cross-compile, dpkg-deb, rpmbuild,
|
|
* appimagetool, cargo-xwin + wine) is intentionally NOT implemented here.
|
|
* Phase 1 stores the bundle definition + serves a public download portal
|
|
* that reports each platform as "pending". Phase 2 will plug a queue
|
|
* into this service and start producing real artifacts keyed by
|
|
* `branding_hash`.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const conn = require('./agentBundleConnection');
|
|
|
|
// Supported delivery targets. The portal renders one card per entry.
|
|
const PLATFORMS = [
|
|
{ platform: 'windows', arch: 'x64', format: 'portable', label: 'Windows portable (.exe)' },
|
|
{ platform: 'windows', arch: 'x64', format: 'installed', label: 'Windows installed (.msi)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'portable', label: 'Linux universal portable (.tar.gz)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'appimage', label: 'Linux portable (AppImage)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'installed', label: 'Linux Debian/Ubuntu (.deb)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'rpm', label: 'Linux Fedora/RHEL (.rpm)' },
|
|
];
|
|
|
|
const SUPPORTED_LANGS = [
|
|
'ar', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hi', 'hu', 'id', 'it',
|
|
'ja', 'ko', 'nb', 'nl', 'pl', 'pt', 'ro', 'sv', 'th', 'tr', 'uk', 'vi',
|
|
'zh', 'zh-TW',
|
|
];
|
|
|
|
const LOCALE_LABELS = {
|
|
ar: 'العربية',
|
|
cs: 'Čeština',
|
|
da: 'Dansk',
|
|
de: 'Deutsch',
|
|
en: 'English',
|
|
es: 'Español',
|
|
fi: 'Suomi',
|
|
fr: 'Français',
|
|
hi: 'हिन्दी',
|
|
hu: 'Magyar',
|
|
id: 'Bahasa Indonesia',
|
|
it: 'Italiano',
|
|
ja: '日本語',
|
|
ko: '한국어',
|
|
nb: 'Norsk Bokmål',
|
|
nl: 'Nederlands',
|
|
pl: 'Polski',
|
|
pt: 'Português',
|
|
ro: 'Română',
|
|
sv: 'Svenska',
|
|
th: 'ไทย',
|
|
tr: 'Türkçe',
|
|
uk: 'Українська',
|
|
vi: 'Tiếng Việt',
|
|
zh: '简体中文',
|
|
'zh-TW': '繁體中文',
|
|
};
|
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
|
|
// Reasonable upper bounds: enough room for a base64 PNG/SVG logo (~10 MB),
|
|
// not enough to be a DoS vector.
|
|
const MAX_LOGO_BYTES = 10 * 1024 * 1024; // 10 MB encoded
|
|
const MAX_SHORT_TEXT = 500;
|
|
const MAX_NAME = 100;
|
|
const MAX_CONTACT = 200;
|
|
|
|
function clip(input, max) {
|
|
if (typeof input !== 'string') return '';
|
|
return input.trim().slice(0, max);
|
|
}
|
|
|
|
function isDataUrlImage(s) {
|
|
if (typeof s !== 'string' || s.length === 0) return false;
|
|
if (!s.startsWith('data:image/')) return false;
|
|
const idx = s.indexOf(';base64,');
|
|
if (idx === -1) return false;
|
|
if (Buffer.byteLength(s, 'utf8') > MAX_LOGO_BYTES) return false;
|
|
return true;
|
|
}
|
|
|
|
function isLikelyUrl(s) {
|
|
if (typeof s !== 'string' || s.length === 0) return false;
|
|
try {
|
|
const u = new URL(s);
|
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate + normalize a branding payload from the operator panel.
|
|
* Returns { valid, errors[], normalized }
|
|
*/
|
|
function validateBranding(input = {}) {
|
|
const errors = [];
|
|
const out = {};
|
|
|
|
out.company_name = clip(input.company_name || input.companyName, MAX_NAME);
|
|
if (!out.company_name) errors.push('company_name_required');
|
|
|
|
out.short_text = clip(input.short_text || input.shortText, MAX_SHORT_TEXT);
|
|
out.contact_email = clip(input.contact_email || input.contactEmail, MAX_CONTACT);
|
|
out.contact_phone = clip(input.contact_phone || input.contactPhone, MAX_CONTACT);
|
|
out.contact_url = clip(input.contact_url || input.contactUrl, MAX_CONTACT);
|
|
|
|
if (out.contact_url) {
|
|
// UX: auto-prepend https:// if the operator typed a bare domain
|
|
// (e.g. "insolve.pl") so the most common input still validates.
|
|
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(out.contact_url)) {
|
|
const candidate = 'https://' + out.contact_url;
|
|
if (isLikelyUrl(candidate)) {
|
|
out.contact_url = candidate;
|
|
}
|
|
}
|
|
if (!isLikelyUrl(out.contact_url)) {
|
|
errors.push('contact_url_invalid');
|
|
}
|
|
}
|
|
|
|
const logo = input.logo_data_url || input.logoDataUrl || '';
|
|
if (logo) {
|
|
if (!isDataUrlImage(logo)) {
|
|
errors.push('logo_invalid');
|
|
out.logo_data_url = '';
|
|
} else {
|
|
out.logo_data_url = logo;
|
|
}
|
|
} else {
|
|
out.logo_data_url = '';
|
|
}
|
|
|
|
out.primary_color = (input.primary_color || input.primaryColor || '#2563eb');
|
|
if (!HEX_COLOR.test(out.primary_color)) {
|
|
errors.push('primary_color_invalid');
|
|
out.primary_color = '#2563eb';
|
|
}
|
|
out.primary_color = out.primary_color.toLowerCase();
|
|
|
|
out.accent_color = (input.accent_color || input.accentColor || '#1e293b');
|
|
if (!HEX_COLOR.test(out.accent_color)) {
|
|
errors.push('accent_color_invalid');
|
|
out.accent_color = '#1e293b';
|
|
}
|
|
out.accent_color = out.accent_color.toLowerCase();
|
|
|
|
const colorFields = [
|
|
['background_color', '#0f172a', 'background_color'],
|
|
['surface_color', '#1e293b', 'surface_color'],
|
|
['text_color', '#e2e8f0', 'text_color'],
|
|
['text_muted_color', '#94a3b8', 'text_muted_color'],
|
|
['status_ready_color', '#22c55e', 'status_ready_color'],
|
|
['header_text_color', '#ffffff', 'header_text_color'],
|
|
];
|
|
for (const [key, fallback, errKey] of colorFields) {
|
|
const alt = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
out[key] = (input[key] || input[alt] || fallback);
|
|
if (!HEX_COLOR.test(out[key])) {
|
|
errors.push(errKey + '_invalid');
|
|
out[key] = fallback;
|
|
}
|
|
out[key] = out[key].toLowerCase();
|
|
}
|
|
|
|
out.allow_unattended = !!(input.allow_unattended ?? input.allowUnattended ?? false);
|
|
|
|
out.default_lang = String(input.default_lang || input.defaultLang || 'en');
|
|
if (!SUPPORTED_LANGS.includes(out.default_lang)) {
|
|
out.default_lang = 'en';
|
|
}
|
|
|
|
// Connection profile — host/TLS set by operator; URLs + token filled server-side.
|
|
out.server_host = clip(input.server_host || input.serverHost, 253);
|
|
if (out.server_host) {
|
|
const norm = conn.normalizeServerHost(out.server_host);
|
|
if (!norm.valid) {
|
|
errors.push(norm.error);
|
|
} else {
|
|
out.server_host = norm.host;
|
|
}
|
|
} else {
|
|
errors.push('server_host_required');
|
|
}
|
|
out.use_https = !!(input.use_https ?? input.useHttps ?? conn.defaultUseHttps());
|
|
|
|
// Never accept enrollment_token from the browser — issued by backend only.
|
|
if (input.server && typeof input.server === 'object') {
|
|
out.server = {
|
|
address: clip(input.server.address || '', MAX_CONTACT),
|
|
api_url: clip(input.server.api_url || '', MAX_CONTACT),
|
|
public_key: clip(input.server.public_key || '', 256),
|
|
};
|
|
} else {
|
|
out.server = { address: '', api_url: '', public_key: '' };
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, normalized: out };
|
|
}
|
|
|
|
/**
|
|
* Stable hash of normalized branding. Sorts keys recursively so logically
|
|
* identical branding produces an identical hash regardless of insertion order.
|
|
* Used as the Phase 2 cache key — bundles sharing this hash reuse artifacts.
|
|
*/
|
|
function hashBranding(normalized) {
|
|
const json = stableStringify(normalized || {});
|
|
return crypto.createHash('sha256').update(json).digest('hex');
|
|
}
|
|
|
|
function stableStringify(value) {
|
|
if (value === null || typeof value !== 'object') {
|
|
return JSON.stringify(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return '[' + value.map(stableStringify).join(',') + ']';
|
|
}
|
|
const keys = Object.keys(value).sort();
|
|
return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
}
|
|
|
|
/** 12 hex chars = 48 bits of entropy. Enough for a non-guessable public ID. */
|
|
function generateBundleId() {
|
|
return crypto.randomBytes(6).toString('hex');
|
|
}
|
|
|
|
const MAX_SLUG_LENGTH = 32;
|
|
const MIN_SLUG_LENGTH = 2;
|
|
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
|
|
const TRANSLIT_MAP = {
|
|
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z',
|
|
'ä': 'a', 'ö': 'o', 'ü': 'u', 'ß': 'ss', 'æ': 'ae', 'ø': 'o', 'å': 'a',
|
|
'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', 'ů': 'u', 'ý': 'y', 'ž': 'z',
|
|
};
|
|
|
|
function transliterate(input) {
|
|
return String(input || '')
|
|
.split('')
|
|
.map(ch => TRANSLIT_MAP[ch] ?? TRANSLIT_MAP[ch.toLowerCase()] ?? ch)
|
|
.join('');
|
|
}
|
|
|
|
/** Turn a human label into a URL-safe slug segment (may be empty). */
|
|
function slugifyName(name) {
|
|
let slug = transliterate(String(name || '').trim().toLowerCase());
|
|
slug = slug
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
if (slug.length > MAX_SLUG_LENGTH) {
|
|
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-$/, '');
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
/** Normalize operator-provided slug input. */
|
|
function normalizeSlug(input) {
|
|
return slugifyName(input);
|
|
}
|
|
|
|
function validateSlug(slug) {
|
|
if (!slug || typeof slug !== 'string') {
|
|
return { valid: false, error: 'slug_invalid' };
|
|
}
|
|
if (slug.length < MIN_SLUG_LENGTH) {
|
|
return { valid: false, error: 'slug_too_short' };
|
|
}
|
|
if (slug.length > MAX_SLUG_LENGTH) {
|
|
return { valid: false, error: 'slug_too_long' };
|
|
}
|
|
if (!SLUG_PATTERN.test(slug)) {
|
|
return { valid: false, error: 'slug_invalid' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Pick a unique public slug. Uses `preferred`, then `name`, then `fallbackId`.
|
|
* `isTaken(slug)` must return true when the slug is unavailable.
|
|
*/
|
|
function allocateUniqueSlug({ preferred, name, fallbackId, isTaken }) {
|
|
const tryBase = (base) => {
|
|
if (!base) return '';
|
|
const normalized = normalizeSlug(base);
|
|
return validateSlug(normalized).valid ? normalized : '';
|
|
};
|
|
|
|
let base = tryBase(preferred) || tryBase(name) || tryBase(fallbackId) || '';
|
|
if (!base && fallbackId) {
|
|
base = String(fallbackId).slice(0, MAX_SLUG_LENGTH);
|
|
}
|
|
if (!base) {
|
|
base = generateBundleId();
|
|
}
|
|
|
|
let candidate = base;
|
|
let suffix = 2;
|
|
while (isTaken(candidate)) {
|
|
const tail = `-${suffix}`;
|
|
candidate = base.slice(0, Math.max(MIN_SLUG_LENGTH, MAX_SLUG_LENGTH - tail.length)) + tail;
|
|
suffix += 1;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
/** Public URL segment for a bundle row — slug when set, legacy hex id otherwise. */
|
|
function publicBundleId(row) {
|
|
if (!row) return '';
|
|
return row.slug || row.bundle_id || '';
|
|
}
|
|
|
|
/** Default branding used as a starting point in the editor. */
|
|
function defaultBranding() {
|
|
return {
|
|
company_name: '',
|
|
short_text: '',
|
|
contact_email: '',
|
|
contact_phone: '',
|
|
contact_url: '',
|
|
logo_data_url: '',
|
|
primary_color: '#2563eb',
|
|
accent_color: '#1e293b',
|
|
background_color: '#0f172a',
|
|
surface_color: '#1e293b',
|
|
text_color: '#e2e8f0',
|
|
text_muted_color: '#94a3b8',
|
|
status_ready_color: '#22c55e',
|
|
header_text_color: '#ffffff',
|
|
allow_unattended: false,
|
|
default_lang: 'en',
|
|
server_host: '',
|
|
use_https: conn.defaultUseHttps(),
|
|
server: { address: '', api_url: '', public_key: '' },
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
PLATFORMS,
|
|
SUPPORTED_LANGS,
|
|
LOCALE_LABELS,
|
|
MAX_SLUG_LENGTH,
|
|
MIN_SLUG_LENGTH,
|
|
validateBranding,
|
|
hashBranding,
|
|
generateBundleId,
|
|
slugifyName,
|
|
normalizeSlug,
|
|
validateSlug,
|
|
allocateUniqueSlug,
|
|
publicBundleId,
|
|
defaultBranding,
|
|
};
|