Add bare-metal installer and in-app backup/restore

install.sh: no-Docker one-liner for cloud VPS
- Installs Node.js 20 via NodeSource if missing (Ubuntu/Debian/RHEL/Rocky)
- Clones to /opt/capsule, builds, creates a systemd service
- Re-running upgrades in place (git pull + rebuild)

Backup & Restore (Settings → Backup & Restore tab):
- Download Backup: streams all data/ as a .tar.gz (pure Node zlib, no deps)
- Restore: upload a .tar.gz to replace server data, auto sign-out after

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 09:29:45 -04:00
1 parent 08388431ac
commit 028265dd60
5 files changed
+386 -182

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileStorageService } from '@/lib/fileStorage';
import { buildTarGz } from '@/lib/tarball';
import path from 'path';
import fs from 'fs';
const DATA_DIR = path.join(process.cwd(), 'data');
export async function GET(request: NextRequest) {
try {
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (!fs.existsSync(DATA_DIR)) {
return NextResponse.json({ error: 'No data directory found' }, { status: 404 });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `capsule-backup-${timestamp}.tar.gz`;
const body = buildTarGz(DATA_DIR);
return new NextResponse(body as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': body.length.toString(),
},
});
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Backup failed' }, { status: 500 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { FileStorageService } from '@/lib/fileStorage';
import { extractTarGz } from '@/lib/tarball';
import path from 'path';
import fs from 'fs';
const DATA_DIR = path.join(process.cwd(), 'data');
export async function POST(request: NextRequest) {
try {
const token = request.headers.get('x-session-token');
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const session = await FileStorageService.validateSession(token);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const formData = await request.formData();
const file = formData.get('backup') as File | null;
if (!file) return NextResponse.json({ error: 'No backup file provided' }, { status: 400 });
const buffer = Buffer.from(await file.arrayBuffer());
// Backup current data before overwriting
const backupPath = DATA_DIR + '.bak';
if (fs.existsSync(DATA_DIR)) {
if (fs.existsSync(backupPath)) fs.rmSync(backupPath, { recursive: true });
fs.cpSync(DATA_DIR, backupPath, { recursive: true });
}
fs.mkdirSync(DATA_DIR, { recursive: true });
await extractTarGz(buffer, DATA_DIR);
return NextResponse.json({ success: true, message: 'Restore complete. Please sign in again.' });
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Restore failed' }, { status: 500 });
}
}
+103 -1
View File
@@ -10,7 +10,7 @@ interface SettingsProps {
}
export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [activeTab, setActiveTab] = useState<'password' | 'export' | 'delete'>('password');
const [activeTab, setActiveTab] = useState<'password' | 'export' | 'backup' | 'delete'>('password');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
const [deleteInput, setDeleteInput] = useState('');
@@ -233,6 +233,69 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
}
};
// ── Backup & Restore ────────────────────────────────────────────────────────
const [backupLoading, setBackupLoading] = useState(false);
const [restoreLoading, setRestoreLoading] = useState(false);
const [backupMessage, setBackupMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleBackup = async () => {
setBackupLoading(true);
setBackupMessage(null);
try {
const token = AuthService.getSessionToken();
const res = await fetch('/api/admin/backup', { headers: { 'x-session-token': token ?? '' } });
if (!res.ok) { const e = await res.json(); throw new Error(e.error); }
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? 'capsule-backup.tar.gz';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
setBackupMessage({ type: 'success', text: 'Backup downloaded successfully.' });
} catch (e: any) {
setBackupMessage({ type: 'error', text: e.message || 'Backup failed' });
} finally {
setBackupLoading(false);
}
};
const handleRestore = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
setBackupMessage({ type: 'error', text: 'File must be a .tar.gz backup.' });
return;
}
if (!confirm('This will replace ALL server data with the backup. Are you sure?')) {
e.target.value = '';
return;
}
setRestoreLoading(true);
setBackupMessage(null);
try {
const token = AuthService.getSessionToken();
const form = new FormData();
form.append('backup', file);
const res = await fetch('/api/admin/restore', {
method: 'POST',
headers: { 'x-session-token': token ?? '' },
body: form,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setBackupMessage({ type: 'success', text: data.message });
setTimeout(() => AuthService.signOut(), 3000);
} catch (e: any) {
setBackupMessage({ type: 'error', text: e.message || 'Restore failed' });
} finally {
setRestoreLoading(false);
e.target.value = '';
}
};
const handleClearData = async () => {
if (clearDataInput !== 'CLEAR ALL' || !showClearDataConfirm) return;
@@ -269,6 +332,7 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
{[
{ id: 'password', label: 'Password & Security' },
{ id: 'export', label: 'Data & Export' },
{ id: 'backup', label: 'Backup & Restore' },
{ id: 'delete', label: 'Account' },
].map((tab) => (
<button
@@ -501,6 +565,44 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
</div>
)}
{/* Backup & Restore Tab */}
{activeTab === 'backup' && (
<div className="space-y-6 max-w-md">
{backupMessage && (
<div className={`p-3 rounded-lg text-sm ${backupMessage.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
{backupMessage.text}
</div>
)}
{/* Backup */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-1">Download Backup</h3>
<p className="text-sm text-gray-500 mb-4">
Downloads a <code>.tar.gz</code> archive of all server data — accounts, transactions, users, and sessions. Use this to migrate to a new server.
</p>
<button onClick={handleBackup} disabled={backupLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity text-sm font-medium">
{backupLoading ? 'Creating backup…' : 'Download Backup'}
</button>
</div>
{/* Restore */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-1">Restore from Backup</h3>
<p className="text-sm text-gray-500 mb-1">
Upload a backup file to restore all data. <strong>This replaces everything on this server.</strong>
</p>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-4">
You will be signed out automatically after a successful restore.
</p>
<label className={`flex items-center justify-center w-full py-2 px-4 rounded-md text-sm font-medium cursor-pointer transition-opacity ${restoreLoading ? 'bg-gray-300 text-gray-500 pointer-events-none' : 'bg-amber-600 hover:bg-amber-700 text-white'}`}>
{restoreLoading ? 'Restoring…' : 'Choose Backup File…'}
<input type="file" accept=".tar.gz,.tgz" onChange={handleRestore} className="hidden" disabled={restoreLoading} />
</label>
</div>
</div>
)}
{/* Delete Tab */}
{activeTab === 'delete' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
+88
View File
@@ -0,0 +1,88 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
// Minimal tar builder (POSIX ustar) — no deps
function encodeHeader(name: string, size: number): Buffer {
const h = Buffer.alloc(512, 0);
const write = (val: string, offset: number, len: number) =>
h.write(val.slice(0, len), offset, 'ascii');
write(name.slice(0, 99), 0, 100); // name
write('0000644\0', 100, 8); // mode
write('0000000\0', 108, 8); // uid
write('0000000\0', 116, 8); // gid
write(size.toString(8).padStart(11, '0') + '\0', 124, 12); // size
write(Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0', 136, 12); // mtime
h.write(' ', 148, 8, 'ascii'); // checksum placeholder
h[156] = 0x30; // type '0' = regular file
write('ustar\0', 257, 6);
write('00', 263, 2);
let ck = 0;
for (let i = 0; i < 512; i++) ck += h[i];
h.write(ck.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
return h;
}
function walkDir(dir: string): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) results.push(...walkDir(full));
else if (entry.isFile()) results.push(full);
}
return results;
}
export function buildTarGz(sourceDir: string): Buffer {
const files = walkDir(sourceDir);
const blocks: Buffer[] = [];
for (const file of files) {
const content = fs.readFileSync(file);
const relName = 'data/' + path.relative(sourceDir, file);
blocks.push(encodeHeader(relName, content.length));
// file content padded to 512-byte boundary
const padded = Buffer.alloc(Math.ceil(content.length / 512) * 512, 0);
content.copy(padded);
blocks.push(padded);
}
// two 512-byte zero blocks = end of archive
blocks.push(Buffer.alloc(1024, 0));
const tar = Buffer.concat(blocks);
return zlib.gzipSync(tar);
}
export async function extractTarGz(buffer: Buffer, destDir: string): Promise<void> {
const tar = zlib.gunzipSync(buffer);
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.slice(offset, offset + 512);
offset += 512;
// All-zero block = end of archive
if (header.every(b => b === 0)) break;
const name = header.toString('ascii', 0, 100).replace(/\0/g, '').trim();
const size = parseInt(header.toString('ascii', 124, 136).trim(), 8) || 0;
const type = String.fromCharCode(header[156]);
const dataBlocks = Math.ceil(size / 512) * 512;
if (type === '0' || type === '\0') {
// Strip leading 'data/' prefix and resolve inside destDir
const rel = name.replace(/^data\//, '');
if (!rel) { offset += dataBlocks; continue; }
const dest = path.join(destDir, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, tar.slice(offset, offset + size));
}
offset += dataBlocks;
}
}