mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-05 05:23:18 +00:00
The first page of the setup wizard now offers Restore from a backup next to Get Started. It takes a backup made with Messages ticked and brings back the accounts, channels, messages, settings and (when included) uploaded files, so there is no new admin to create and then import over. Data already on the computer is kept as .pre-restore copies, a bad or partial backup is refused before anything is touched, and the new .env names the restored admin. The restore itself is installer/restore-backup.js, using the same extractor as a restore from Settings.
440 lines
18 KiB
JavaScript
440 lines
18 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
|
// Haven — Web-based Installer Server
|
|
// Uses ONLY Node.js built-in modules (no npm install needed)
|
|
// ═══════════════════════════════════════════════════════════
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawn, execSync } = require('child_process');
|
|
const os = require('os');
|
|
|
|
const HAVEN_DIR = path.resolve(__dirname, '..');
|
|
const IS_WIN = process.platform === 'win32';
|
|
const DATA_DIR = IS_WIN
|
|
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Haven')
|
|
: path.join(os.homedir(), '.haven');
|
|
// A backup uploaded for "Restore from a backup" waits here until the install
|
|
// step unpacks it. It sits in the data folder's own temp folder, like a restore
|
|
// from Settings, because a backup can be many gigabytes.
|
|
const UPLOADED_BACKUP = path.join(DATA_DIR, 'tmp-restore', 'installer-backup.zip');
|
|
|
|
// ── Serve the installer HTML ──────────────────────────────
|
|
const indexHtml = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
|
|
|
// ── HTTP Server ───────────────────────────────────────────
|
|
const server = http.createServer((req, res) => {
|
|
// Only allow localhost connections
|
|
const host = req.socket.remoteAddress;
|
|
if (host !== '127.0.0.1' && host !== '::1' && host !== '::ffff:127.0.0.1') {
|
|
res.writeHead(403); res.end('Forbidden'); return;
|
|
}
|
|
|
|
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(indexHtml);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/check') {
|
|
let nodeVersion = '';
|
|
nodeVersion = process.version;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
platform: process.platform,
|
|
nodeVersion,
|
|
dataDir: DATA_DIR,
|
|
alreadyInstalled: fs.existsSync(path.join(DATA_DIR, '.tunnel_configured'))
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/backup-upload') {
|
|
receiveBackup(req, res);
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/install') {
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', () => {
|
|
let config;
|
|
try { config = JSON.parse(body); } catch {
|
|
res.writeHead(400); res.end('Bad JSON'); return;
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
});
|
|
runInstall(config, res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/launch') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end('{"ok":true}');
|
|
launchHaven();
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404); res.end('Not found');
|
|
});
|
|
|
|
// ── Pick a random available port and start ────────────────
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
const url = `http://127.0.0.1:${port}`;
|
|
console.log(`\n Haven Installer running at ${url}\n`);
|
|
|
|
// Open browser
|
|
try {
|
|
if (IS_WIN) {
|
|
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
} else if (process.platform === 'darwin') {
|
|
spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
} else {
|
|
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
}
|
|
} catch {
|
|
console.log(` Open this URL in your browser: ${url}`);
|
|
}
|
|
});
|
|
|
|
// ── SSE helper ────────────────────────────────────────────
|
|
function send(res, data) {
|
|
try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch { /* the browser closed the progress page: nothing left to tell */ }
|
|
}
|
|
|
|
// ── Run a command and return a promise ────────────────────
|
|
function run(cmd, args, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(cmd, args, {
|
|
cwd: opts.cwd || HAVEN_DIR,
|
|
env: { ...process.env, ...opts.env },
|
|
shell: IS_WIN,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
let out = '', err = '';
|
|
child.stdout.on('data', d => out += d);
|
|
child.stderr.on('data', d => err += d);
|
|
child.on('close', code => {
|
|
if (code === 0) resolve(out);
|
|
else reject(new Error(err || out || `Exit code ${code}`));
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// ── Backup upload ("Restore from a backup") ──────────────
|
|
// The browser sends the file itself as the request body. It is streamed to
|
|
// disk, never held in memory, since a backup with files can be very large.
|
|
function removeUploadedBackup() {
|
|
try { fs.rmSync(UPLOADED_BACKUP, { force: true }); }
|
|
catch (err) { console.warn('[Installer] could not remove the uploaded backup:', err.message); }
|
|
}
|
|
|
|
function receiveBackup(req, res) {
|
|
const reply = (status, obj) => {
|
|
if (res.headersSent) return;
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
try {
|
|
fs.mkdirSync(path.dirname(UPLOADED_BACKUP), { recursive: true });
|
|
} catch (err) {
|
|
reply(500, { error: 'Could not create ' + path.dirname(UPLOADED_BACKUP) + ': ' + err.message });
|
|
return;
|
|
}
|
|
const out = fs.createWriteStream(UPLOADED_BACKUP);
|
|
let failed = false;
|
|
const fail = (err) => {
|
|
if (failed) return;
|
|
failed = true;
|
|
out.destroy();
|
|
removeUploadedBackup();
|
|
reply(500, { error: 'Could not save the backup: ' + err.message });
|
|
};
|
|
req.on('error', fail);
|
|
out.on('error', fail);
|
|
out.on('finish', () => {
|
|
if (failed) return;
|
|
// Every zip starts with "PK". Saying so now beats finding out after the
|
|
// dependencies have installed.
|
|
try {
|
|
const head = Buffer.alloc(2);
|
|
const fd = fs.openSync(UPLOADED_BACKUP, 'r');
|
|
try { fs.readSync(fd, head, 0, 2, 0); } finally { fs.closeSync(fd); }
|
|
if (head.toString('latin1') !== 'PK') {
|
|
removeUploadedBackup();
|
|
reply(400, { error: 'That file is not a Haven backup. Pick the .zip file Haven gave you.' });
|
|
return;
|
|
}
|
|
reply(200, { ok: true, bytes: fs.statSync(UPLOADED_BACKUP).size });
|
|
} catch (err) {
|
|
fail(err);
|
|
}
|
|
});
|
|
req.pipe(out);
|
|
}
|
|
|
|
// Runs installer/restore-backup.js and passes its progress along. Resolves
|
|
// with { serverName, adminUsername } from the restored database.
|
|
function runRestore(onProgress) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [path.join(__dirname, 'restore-backup.js'), UPLOADED_BACKUP, DATA_DIR], {
|
|
cwd: HAVEN_DIR,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
let buffered = '', errText = '', result = null;
|
|
child.stdout.on('data', (chunk) => {
|
|
buffered += chunk;
|
|
const lines = buffered.split('\n');
|
|
buffered = lines.pop();
|
|
for (const line of lines) {
|
|
let msg;
|
|
try { msg = JSON.parse(line); } catch { continue; } // only the script's own JSON lines carry anything
|
|
if (typeof msg.progress === 'number') onProgress(msg.progress);
|
|
else if ('ok' in msg) result = msg;
|
|
}
|
|
});
|
|
child.stderr.on('data', (chunk) => { errText += chunk; });
|
|
child.on('error', reject);
|
|
child.on('close', () => {
|
|
if (result && result.ok) resolve(result);
|
|
else reject(new Error((result && result.error) || errText.trim() || 'The restore stopped without saying why'));
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Installation pipeline ─────────────────────────────────
|
|
async function runInstall(config, res) {
|
|
const { serverName, adminUser, adminPass, tunnel } = config;
|
|
// Restore mode brings the accounts, name and settings from a backup, so no
|
|
// admin account is made and the server name is left as the backup has it.
|
|
const restore = config.restore === true;
|
|
let hasError = false;
|
|
let restored = null;
|
|
|
|
try {
|
|
// ── Step 1: Install npm dependencies ──
|
|
send(res, { step: 'deps', state: 'active', label: 'Installing dependencies\u2026', progress: 5 });
|
|
try {
|
|
const npmCmd = IS_WIN ? 'npm.cmd' : 'npm';
|
|
const npmArgs = ['install', '--no-audit', '--no-fund'];
|
|
if (tunnel === 'localtunnel') npmArgs.push('localtunnel');
|
|
await run(npmCmd, npmArgs);
|
|
send(res, { step: 'deps', state: 'done', label: 'Dependencies installed', progress: 35 });
|
|
} catch (e) {
|
|
send(res, { step: 'deps', state: 'error', label: 'npm install failed: ' + e.message, progress: 35 });
|
|
hasError = true;
|
|
}
|
|
|
|
// ── Step 2: Create data directory ──
|
|
let envCreated = false;
|
|
send(res, { step: 'datadir', state: 'active', label: 'Creating data directory\u2026', progress: 40 });
|
|
try {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const envExample = path.join(HAVEN_DIR, '.env.example');
|
|
const envDest = path.join(DATA_DIR, '.env');
|
|
if (fs.existsSync(envExample) && !fs.existsSync(envDest)) {
|
|
envCreated = true;
|
|
let envContent = fs.readFileSync(envExample, 'utf8');
|
|
if (adminUser) envContent = envContent.replace(/ADMIN_USERNAME=.*/,'ADMIN_USERNAME=' + adminUser);
|
|
envContent += '\nFCM_RELAY_URL=https://us-central1-amni-haven.cloudfunctions.net/sendPush\n';
|
|
envContent += 'FCM_PUSH_KEY=YOUR_GLOBAL_SECRET_KEY_HERE\n';
|
|
fs.writeFileSync(envDest, envContent);
|
|
}
|
|
send(res, { step: 'datadir', state: 'done', label: 'Data directory ready', progress: 45 });
|
|
} catch (e) {
|
|
send(res, { step: 'datadir', state: 'error', label: 'Data dir failed: ' + e.message, progress: 45 });
|
|
hasError = true;
|
|
}
|
|
|
|
// ── Step 2b: Restore the backup (restore mode) ──
|
|
if (restore) {
|
|
send(res, { step: 'restore', state: 'active', label: 'Restoring your backup\u2026', progress: 46 });
|
|
try {
|
|
if (!fs.existsSync(UPLOADED_BACKUP)) throw new Error('No backup was uploaded');
|
|
restored = await runRestore((pct) => send(res, {
|
|
step: 'restore', state: 'active', label: `Restoring your backup\u2026 ${pct}%`, progress: 46 + Math.floor(pct * 0.04)
|
|
}));
|
|
// The .env made above names the first admin, as the original install did.
|
|
const envDest = path.join(DATA_DIR, '.env');
|
|
if (envCreated && restored.adminUsername && /^[A-Za-z0-9_.-]+$/.test(restored.adminUsername)) {
|
|
const envContent = fs.readFileSync(envDest, 'utf8').replace(/ADMIN_USERNAME=.*/, 'ADMIN_USERNAME=' + restored.adminUsername);
|
|
fs.writeFileSync(envDest, envContent);
|
|
}
|
|
send(res, { step: 'restore', state: 'done', label: 'Backup restored', progress: 50 });
|
|
} catch (e) {
|
|
// Going on would set up an empty server in its place, so stop here.
|
|
send(res, { step: 'restore', state: 'error', label: 'Restore failed: ' + e.message, progress: 50 });
|
|
send(res, { done: true, error: true, message: e.message });
|
|
res.end();
|
|
return;
|
|
} finally {
|
|
removeUploadedBackup();
|
|
}
|
|
}
|
|
|
|
// ── Step 3: Generate SSL certificate ──
|
|
send(res, { step: 'ssl', state: 'active', label: 'Generating SSL certificate\u2026', progress: 50 });
|
|
const certDir = path.join(DATA_DIR, 'certs');
|
|
const certPath = path.join(certDir, 'cert.pem');
|
|
if (fs.existsSync(certPath)) {
|
|
send(res, { step: 'ssl', state: 'done', label: 'SSL certificate exists', progress: 60 });
|
|
} else {
|
|
try {
|
|
fs.mkdirSync(certDir, { recursive: true });
|
|
require('../src/selfsignedCert').ensureCerts(certDir);
|
|
send(res, { step: 'ssl', state: 'done', label: 'SSL certificate generated', progress: 60 });
|
|
} catch (err) {
|
|
send(res, { step: 'ssl', state: 'error', label: `SSL generation failed: ${err.message}`, progress: 60 });
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Configure server ──
|
|
send(res, { step: 'config', state: 'active', label: 'Configuring server\u2026', progress: 65 });
|
|
try {
|
|
const tunnelEnabled = (tunnel === 'cloudflare' || tunnel === 'localtunnel') ? 'true' : 'false';
|
|
const tunnelProvider = tunnel === 'cloudflare' ? 'cloudflared' :
|
|
tunnel === 'localtunnel' ? 'localtunnel' : '';
|
|
|
|
// Build a configuration script that uses the project's own database module
|
|
// Write to a temp file to avoid shell escaping issues on Windows
|
|
const configScript = `
|
|
const { initDatabase, getDb } = require('./src/database');
|
|
initDatabase();
|
|
const db = getDb();
|
|
${restore ? '' : `db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('server_name',?)").run(${JSON.stringify(serverName || 'Haven')});`}
|
|
db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('tunnel_enabled',?)").run(${JSON.stringify(tunnelEnabled)});
|
|
db.prepare("INSERT OR REPLACE INTO server_settings(key,value) VALUES('tunnel_provider',?)").run(${JSON.stringify(tunnelProvider)});
|
|
${adminUser && adminPass && !restore ? `
|
|
const bcrypt = require('bcryptjs');
|
|
const hash = bcrypt.hashSync(${JSON.stringify(adminPass)}, 12);
|
|
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(${JSON.stringify(adminUser)});
|
|
if (!existing) {
|
|
db.prepare("INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)").run(${JSON.stringify(adminUser)}, hash);
|
|
console.log('Admin account created');
|
|
} else {
|
|
db.prepare("UPDATE users SET is_admin = 1, password_hash = ? WHERE id = ?").run(hash, existing.id);
|
|
console.log('Existing user promoted to admin and password updated');
|
|
}
|
|
` : ''}
|
|
`;
|
|
|
|
const tmpScript = path.join(HAVEN_DIR, `haven-config-${Date.now()}.js`);
|
|
fs.writeFileSync(tmpScript, configScript);
|
|
try {
|
|
await run('node', [tmpScript], { cwd: HAVEN_DIR });
|
|
send(res, { step: 'config', state: 'done', label: 'Server configured', progress: 80 });
|
|
try { fs.unlinkSync(tmpScript); } catch { /* a leftover temp script is harmless; the next run overwrites it */ }
|
|
} catch (runErr) {
|
|
throw new Error(runErr.message || 'Run failed');
|
|
}
|
|
} catch (e) {
|
|
send(res, { step: 'config', state: 'error', label: 'Config failed: ' + e.message, progress: 80 });
|
|
hasError = true;
|
|
}
|
|
|
|
// Mark tunnel as configured
|
|
try {
|
|
fs.writeFileSync(path.join(DATA_DIR, '.tunnel_configured'), 'configured');
|
|
} catch (err) { console.warn('[Installer] could not mark the tunnel as configured:', err.message); }
|
|
|
|
// ── Step 5: Create shortcuts (Windows only) ──
|
|
send(res, { step: 'shortcuts', state: 'active', label: 'Creating shortcuts\u2026', progress: 85 });
|
|
if (IS_WIN) {
|
|
try {
|
|
const desktop = path.join(os.homedir(), 'Desktop');
|
|
const startMenu = path.join(process.env.APPDATA, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
|
const batPath = path.join(HAVEN_DIR, 'Start Haven.bat');
|
|
|
|
const psScript = `
|
|
$shell = New-Object -ComObject WScript.Shell
|
|
$lnk = $shell.CreateShortcut('${desktop.replace(/'/g, "''")}\\\Haven.lnk')
|
|
$lnk.TargetPath = '${batPath.replace(/'/g, "''")}'
|
|
$lnk.WorkingDirectory = '${HAVEN_DIR.replace(/'/g, "''")}'
|
|
$lnk.Description = 'Launch Haven server'
|
|
$lnk.Save()
|
|
$lnk2 = $shell.CreateShortcut('${startMenu.replace(/'/g, "''")}\\\Haven.lnk')
|
|
$lnk2.TargetPath = '${batPath.replace(/'/g, "''")}'
|
|
$lnk2.WorkingDirectory = '${HAVEN_DIR.replace(/'/g, "''")}'
|
|
$lnk2.Description = 'Launch Haven server'
|
|
$lnk2.Save()
|
|
`.replace(/\n/g, '; ');
|
|
|
|
await run('powershell.exe', ['-NoProfile', '-Command', psScript]);
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Desktop & Start Menu shortcuts created', progress: 95 });
|
|
} catch {
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Shortcuts skipped', progress: 95 });
|
|
}
|
|
} else {
|
|
// Linux: create a .desktop file if the desktop directory exists
|
|
try {
|
|
const desktopDir = path.join(os.homedir(), 'Desktop');
|
|
const appsDir = path.join(os.homedir(), '.local', 'share', 'applications');
|
|
const startSh = path.join(HAVEN_DIR, 'start.sh');
|
|
|
|
const desktopEntry = [
|
|
'[Desktop Entry]',
|
|
'Type=Application',
|
|
'Name=Haven',
|
|
'Comment=Launch Haven private chat server',
|
|
`Exec=bash "${startSh}"`,
|
|
`Path=${HAVEN_DIR}`,
|
|
'Terminal=true',
|
|
'Categories=Network;Chat;',
|
|
''
|
|
].join('\n');
|
|
|
|
if (fs.existsSync(desktopDir)) {
|
|
const dFile = path.join(desktopDir, 'Haven.desktop');
|
|
fs.writeFileSync(dFile, desktopEntry);
|
|
try { fs.chmodSync(dFile, 0o755); } catch (err) { console.warn('[Installer] could not make the desktop shortcut runnable:', err.message); }
|
|
}
|
|
fs.mkdirSync(appsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(appsDir, 'Haven.desktop'), desktopEntry);
|
|
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Application shortcut created', progress: 95 });
|
|
} catch {
|
|
send(res, { step: 'shortcuts', state: 'done', label: 'Shortcuts skipped', progress: 95 });
|
|
}
|
|
}
|
|
|
|
send(res, { progress: 100, done: true, error: hasError, restored });
|
|
} catch (e) {
|
|
send(res, { done: true, error: true, message: e.message });
|
|
}
|
|
res.end();
|
|
}
|
|
|
|
// ── Launch Haven server ───────────────────────────────────
|
|
function launchHaven() {
|
|
setTimeout(() => {
|
|
if (IS_WIN) {
|
|
// Launch Start Haven.bat in a new console window
|
|
const batPath = path.join(HAVEN_DIR, 'Start Haven.bat');
|
|
spawn('cmd', ['/c', 'start', '', 'cmd', '/c', batPath], {
|
|
cwd: HAVEN_DIR,
|
|
stdio: 'ignore',
|
|
detached: true
|
|
}).unref();
|
|
} else {
|
|
// Launch start.sh
|
|
const shPath = path.join(HAVEN_DIR, 'start.sh');
|
|
spawn('bash', [shPath], {
|
|
cwd: HAVEN_DIR,
|
|
stdio: 'ignore',
|
|
detached: true
|
|
}).unref();
|
|
}
|
|
|
|
// Shut down installer server after a brief delay
|
|
setTimeout(() => {
|
|
server.close();
|
|
process.exit(0);
|
|
}, 2000);
|
|
}, 500);
|
|
}
|