diff --git a/install.sh b/install.sh index ebbb068..3228913 100755 --- a/install.sh +++ b/install.sh @@ -1,200 +1,141 @@ #!/bin/bash - -# Capsule Budget - One-Line Installer for ZimaOS/Podman/Docker +# Capsule Budget — bare-metal installer (no Docker) # Usage: curl -fsSL https://raw.githubusercontent.com/nmemmert/budget/master/install.sh | bash set -e REPO_URL="https://github.com/nmemmert/budget.git" -INSTALL_DIR="capsule-budget" +INSTALL_DIR="/opt/capsule" +SERVICE_USER="capsule" +PORT=7654 -echo "╔════════════════════════════════════════════════════════════╗" -echo "║ ║" -echo "║ 🌟 Capsule Budget Installer 🌟 ║" -echo "║ by NeCloud ║" -echo "║ ║" -echo "╚════════════════════════════════════════════════════════════╝" -echo "" - -# Check for container engine -echo "🔍 Checking prerequisites..." -if command -v podman &> /dev/null; then - CONTAINER_ENGINE="podman" - echo "✅ Podman found" -elif command -v docker &> /dev/null; then - CONTAINER_ENGINE="docker" - echo "✅ Docker found" -else - echo "❌ No supported container engine found" - echo "Install Podman (recommended on Rocky Linux) or Docker" - exit 1 -fi - -# Check for compose command (Podman first) -if command -v podman-compose &> /dev/null; then - CONTAINER_COMPOSE="podman-compose" - echo "✅ podman-compose found" -elif command -v podman &> /dev/null && podman compose version &> /dev/null 2>&1; then - CONTAINER_COMPOSE="podman compose" - echo "✅ podman compose found" -elif command -v docker-compose &> /dev/null; then - CONTAINER_COMPOSE="docker-compose" - echo "✅ docker-compose found" -elif command -v docker &> /dev/null && docker compose version &> /dev/null 2>&1; then - CONTAINER_COMPOSE="docker compose" - echo "✅ docker compose found" -else - echo "❌ No supported compose command found" - echo "Install one of: podman-compose, podman compose, docker-compose, docker compose" - exit 1 -fi - -# Check if git is available -if command -v git &> /dev/null; then - USE_GIT=true - echo "✅ Git found" -else - USE_GIT=false - echo "⚠️ Git not found, will download files directly" -fi +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓${NC} $*"; } +info() { echo -e "${CYAN}→${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +die() { echo -e "${RED}✗${NC} $*"; exit 1; } echo "" -echo "📦 Installing Capsule Budget..." +echo "╔══════════════════════════════════════════╗" +echo "║ Capsule Budget Installer ║" +echo "╚══════════════════════════════════════════╝" +echo "" -# Remove existing installation if present -if [ -d "$INSTALL_DIR" ]; then - echo "⚠️ Existing installation found" - read -p "Do you want to remove it and reinstall? (y/N): " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "🗑️ Removing old installation..." - rm -rf "$INSTALL_DIR" - else - echo "❌ Installation cancelled" - exit 0 - fi -fi +# Must be root +[ "$EUID" -eq 0 ] || die "Run as root: sudo bash install.sh (or pipe through sudo)" -# Clone or download repository -if [ "$USE_GIT" = true ]; then - echo "📥 Cloning repository..." - git clone "$REPO_URL" "$INSTALL_DIR" +# ── Node.js 20 ──────────────────────────────────────────────────────────────── +install_node() { + info "Installing Node.js 20..." + if command -v apt-get &>/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + elif command -v dnf &>/dev/null; then + curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - + dnf install -y nodejs + elif command -v yum &>/dev/null; then + curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - + yum install -y nodejs + else + die "Unsupported package manager. Install Node.js 20 manually then re-run." + fi +} + +if command -v node &>/dev/null; then + NODE_MAJOR=$(node -e "process.stdout.write(process.versions.node.split('.')[0])") + if [ "$NODE_MAJOR" -ge 20 ]; then + ok "Node.js $(node --version) already installed" + else + warn "Node.js $(node --version) is too old (need 20+)" + install_node + fi else - echo "📥 Downloading repository..." - mkdir -p "$INSTALL_DIR" - cd "$INSTALL_DIR" - - # Download essential files - curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/docker-compose.yml" -o docker-compose.yml - curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/Dockerfile" -o Dockerfile - curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/.env.example" -o .env.example - curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/package.json" -o package.json - - # Note: Full source code still needs to be downloaded - echo "⚠️ For full installation, Git is recommended" - echo "Downloading complete source archive..." - curl -fsSL "https://github.com/nmemmert/budget/archive/refs/heads/master.tar.gz" | tar -xz --strip-components=1 - - cd .. + install_node +fi +ok "Node.js $(node --version)" + +# ── git ─────────────────────────────────────────────────────────────────────── +command -v git &>/dev/null || { + info "Installing git..." + if command -v apt-get &>/dev/null; then apt-get install -y git + elif command -v dnf &>/dev/null; then dnf install -y git + else yum install -y git; fi +} +ok "git $(git --version | awk '{print $3}')" + +# ── Clone / update ──────────────────────────────────────────────────────────── +if [ -d "$INSTALL_DIR/.git" ]; then + info "Updating existing installation at $INSTALL_DIR..." + git -C "$INSTALL_DIR" pull --ff-only +else + info "Cloning to $INSTALL_DIR..." + git clone "$REPO_URL" "$INSTALL_DIR" fi +# ── System user ─────────────────────────────────────────────────────────────── +if ! id "$SERVICE_USER" &>/dev/null; then + info "Creating system user '$SERVICE_USER'..." + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" +fi + +# ── Build ───────────────────────────────────────────────────────────────────── +info "Installing dependencies and building..." cd "$INSTALL_DIR" +npm ci --prefer-offline 2>&1 | tail -3 +npm run build 2>&1 | tail -5 +ok "Build complete" -# Setup environment -echo "" -echo "⚙️ Setting up environment..." +# ── Data directory ──────────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR/data" +chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" +ok "Data directory: $INSTALL_DIR/data" -if [ ! -f .env ]; then - echo "📝 Creating .env file..." - cp .env.example .env - - # Generate encryption key - echo "🔑 Generating encryption key..." - - if command -v openssl &> /dev/null; then - # Use openssl if available - ENCRYPTION_KEY=$(openssl rand -base64 32) - elif [ -r /dev/urandom ]; then - # Fallback to /dev/urandom - ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '\n') - else - # Last resort: generate from random data - ENCRYPTION_KEY=$(cat /dev/random | head -c 32 | base64 | tr -d '\n' 2>/dev/null || echo "PLEASE-CHANGE-THIS-$(date +%s)-$(( RANDOM * RANDOM ))") - echo "⚠️ Warning: Could not generate optimal random key" - echo " Please update ENCRYPTION_KEY in .env with a secure random value" - fi - - # Update .env with generated key (escape special chars for sed) - ESCAPED_KEY=$(echo "$ENCRYPTION_KEY" | sed 's/[&/\\]/\\&/g') - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|change-this-to-a-secure-random-key|$ESCAPED_KEY|" .env - else - sed -i "s|change-this-to-a-secure-random-key|$ESCAPED_KEY|" .env - fi - echo "✅ Generated secure encryption key" +# ── Systemd service ─────────────────────────────────────────────────────────── +SERVICE_FILE="/etc/systemd/system/capsule.service" +cat > "$SERVICE_FILE" </dev/null | awk '{print $1}' || echo "your-server-ip") + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════╗" + echo "║ Installation complete! ║" + echo -e "╚══════════════════════════════════════════╝${NC}" + echo "" + echo " Access Capsule at:" + echo " http://$LOCAL_IP:$PORT" + echo "" + echo " Useful commands:" + echo " systemctl status capsule # check status" + echo " systemctl restart capsule # restart" + echo " journalctl -u capsule -f # view logs" + echo "" + echo " Data lives at: $INSTALL_DIR/data/" + echo " Back up that directory to move to a new server." + echo "" else - echo "ℹ️ .env file already exists, keeping existing configuration" -fi - -# Create data directory -mkdir -p data -chmod 0777 data || true -echo "✅ Data directory created" - -# Build and start -echo "" -echo "🔨 Building container image..." -$CONTAINER_COMPOSE build - -echo "" -echo "🚀 Starting Capsule Budget..." -$CONTAINER_COMPOSE up -d - -# Wait for container -echo "⏳ Waiting for container to start..." -sleep 5 - -# Verify container is running -if $CONTAINER_COMPOSE ps | grep -q "Up"; then - echo "" - echo "╔════════════════════════════════════════════════════════════╗" - echo "║ ║" - echo "║ ✅ Installation Complete! ✅ ║" - echo "║ ║" - echo "╚════════════════════════════════════════════════════════════╝" - echo "" - echo "📊 Capsule Budget is running!" - echo "" - echo "🌐 Access your budget app at:" - echo " • http://localhost:7654" - if command -v hostname &> /dev/null; then - LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "your-device-ip") - echo " • http://$LOCAL_IP:7654" - fi - echo "" - echo "📁 Installation directory: $(pwd)" - echo "💾 Data storage: $(pwd)/data" - echo "" - echo "🔧 Useful commands (run from $(pwd)):" - echo " $CONTAINER_COMPOSE logs -f # View logs" - echo " $CONTAINER_COMPOSE stop # Stop Capsule" - echo " $CONTAINER_COMPOSE restart # Restart Capsule" - echo " $CONTAINER_COMPOSE down # Stop and remove" - echo "" - echo "📚 Documentation:" - echo " • README.md - Getting started guide" - echo " • DEPLOYMENT.md - Deployment details" - echo "" - echo "🎉 Happy budgeting with Capsule!" - echo "" -else - echo "" - echo "❌ Container failed to start" - echo "" - echo "Troubleshooting:" - echo "1. Check logs: $CONTAINER_COMPOSE logs" - echo "2. Verify .env file has ENCRYPTION_KEY set" - echo "3. Check if port 7654 is available" - echo "" - exit 1 + die "Service failed to start. Check logs: journalctl -u capsule -xe" fi diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts new file mode 100644 index 0000000..bcaa482 --- /dev/null +++ b/src/app/api/admin/backup/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts new file mode 100644 index 0000000..326ae71 --- /dev/null +++ b/src/app/api/admin/restore/route.ts @@ -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 }); + } +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index bd33786..10eeb56 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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) => { + 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) => ( + + + {/* Restore */} +
+

Restore from Backup

+

+ Upload a backup file to restore all data. This replaces everything on this server. +

+

+ You will be signed out automatically after a successful restore. +

+ +
+ + )} + {/* Delete Tab */} {activeTab === 'delete' && (
diff --git a/src/lib/tarball.ts b/src/lib/tarball.ts new file mode 100644 index 0000000..09553c8 --- /dev/null +++ b/src/lib/tarball.ts @@ -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 { + 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; + } +}