mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
760c0e933d
Generator UI: web-nodejs/views/generator.ejs + public/js/generator.js + public/css/generator.css. Logo upload up to 10 MB, 16 MB body parser, branding form (product name, colors, server URL, etc.), per-branding hash deduplication.
Build pipeline: web-nodejs/services/agentBundleService.js (queue API + branding hash) and agentBuildWorker.js (DB-backed queue, 5s poll, concurrency 1, 30 min timeout). Spawns 'cargo tauri build --bundles <fmt> [--target <triple>] [--runner cargo-xwin]' per platform under systemd User=root. Loads /etc/betterdesk/build.env at module top so BUILD_USER/CARGO_HOME/PATH survive empty service env. Uses absolute CARGO_BIN/NPM_BIN paths to avoid PATH-resolution issues. Artifact path resolution honors profile.target presence (no triple subdir when omitted).
Toolchain installer: scripts/install-build-toolchain.sh (Rust + targets + cargo-tauri + cargo-xwin + mingw + makensis + dpkg-deb + rpmbuild + appimagetool + pnpm + node), writes /etc/betterdesk/build.env, 12-tool verification. Wired into betterdesk.sh menu as option B with post-install rsync of agent source to /opt/BetterDeskConsole/agent-source/.
Agent download page: web-nodejs/views/agent-download.ejs + public/css/agent-download.css for end-user installer downloads per platform/format with live status.
Branding scaffold (Tauri side): betterdesk-agent-client/src-tauri/src/branding.rs (Branding struct + OnceLock cache + BETTERDESK_AGENT_BRANDING env override + BaseDirectory::Resource resolve). resources/branding.json (dev skeleton). lib.rs registers module + get_branding command. commands.rs exposes get_branding IPC. tauri.conf.json declares resources/branding.json. Frontend integration of get_branding is intentionally pending — alpha.
Database: web-nodejs/services/database.js + dbAdapter.js add agent_bundle_builds + agent_bundles tables with full PostgreSQL + SQLite parity.
i18n: en.json + pl.json + zh-TW.json get ~75 new keys covering generator wizard, build status, download page, and toolchain installer messages.
Validated end-to-end on prod (Ubuntu 24.04, 4-core, PostgreSQL): linux/x64/AppImage built successfully (83.7 MB, 283s) for branding hash 25e2f242. linux/deb in progress, rpm + windows/exe queued.
Known follow-ups (NOT in this commit): SolidJS invoke('get_branding') wiring in App.tsx, betterdesk.ps1 toolchain menu mirror, reset-password.js PostgreSQL support, Docker decision.
This commit was made possible thanks to Insolve.
195 lines
6.7 KiB
JavaScript
195 lines
6.7 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');
|
|
|
|
// Supported delivery targets. The portal renders one card per entry.
|
|
const PLATFORMS = [
|
|
{ platform: 'windows', arch: 'x64', format: 'exe', label: 'Windows (.exe)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'deb', label: 'Debian / Ubuntu (.deb)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'rpm', label: 'Fedora / RHEL (.rpm)' },
|
|
{ platform: 'linux', arch: 'x64', format: 'AppImage', label: 'Linux Universal (.AppImage)' },
|
|
];
|
|
|
|
const SUPPORTED_LANGS = ['en', 'pl', '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();
|
|
|
|
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';
|
|
}
|
|
|
|
// Server-derived fields that should *not* be operator-controlled
|
|
// but must be baked into the binary at build time. The route layer
|
|
// is responsible for injecting these from the system config — we just
|
|
// accept them as an opaque object so the hash covers them too.
|
|
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');
|
|
}
|
|
|
|
/** 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',
|
|
allow_unattended: false,
|
|
default_lang: 'en',
|
|
server: { address: '', api_url: '', public_key: '' },
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
PLATFORMS,
|
|
SUPPORTED_LANGS,
|
|
validateBranding,
|
|
hashBranding,
|
|
generateBundleId,
|
|
defaultBranding,
|
|
};
|