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:
1 parent
08388431ac
commit
028265dd60
5 files changed
+381
-177
No files matched your search
+117
-176
@@ -1,200 +1,141 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Capsule Budget — bare-metal installer (no Docker)
|
||||||
# Capsule Budget - One-Line Installer for ZimaOS/Podman/Docker
|
|
||||||
# Usage: curl -fsSL https://raw.githubusercontent.com/nmemmert/budget/master/install.sh | bash
|
# Usage: curl -fsSL https://raw.githubusercontent.com/nmemmert/budget/master/install.sh | bash
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
REPO_URL="https://github.com/nmemmert/budget.git"
|
REPO_URL="https://github.com/nmemmert/budget.git"
|
||||||
INSTALL_DIR="capsule-budget"
|
INSTALL_DIR="/opt/capsule"
|
||||||
|
SERVICE_USER="capsule"
|
||||||
|
PORT=7654
|
||||||
|
|
||||||
echo "╔════════════════════════════════════════════════════════════╗"
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
echo "║ ║"
|
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
||||||
echo "║ 🌟 Capsule Budget Installer 🌟 ║"
|
info() { echo -e "${CYAN}→${NC} $*"; }
|
||||||
echo "║ by NeCloud ║"
|
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
|
||||||
echo "║ ║"
|
die() { echo -e "${RED}✗${NC} $*"; exit 1; }
|
||||||
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
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📦 Installing Capsule Budget..."
|
echo "╔══════════════════════════════════════════╗"
|
||||||
|
echo "║ Capsule Budget Installer ║"
|
||||||
|
echo "╚══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Remove existing installation if present
|
# Must be root
|
||||||
if [ -d "$INSTALL_DIR" ]; then
|
[ "$EUID" -eq 0 ] || die "Run as root: sudo bash install.sh (or pipe through sudo)"
|
||||||
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
|
|
||||||
|
|
||||||
# Clone or download repository
|
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
||||||
if [ "$USE_GIT" = true ]; then
|
install_node() {
|
||||||
echo "📥 Cloning repository..."
|
info "Installing Node.js 20..."
|
||||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
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
|
else
|
||||||
echo "📥 Downloading repository..."
|
install_node
|
||||||
mkdir -p "$INSTALL_DIR"
|
fi
|
||||||
cd "$INSTALL_DIR"
|
ok "Node.js $(node --version)"
|
||||||
|
|
||||||
# Download essential files
|
# ── git ───────────────────────────────────────────────────────────────────────
|
||||||
curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/docker-compose.yml" -o docker-compose.yml
|
command -v git &>/dev/null || {
|
||||||
curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/Dockerfile" -o Dockerfile
|
info "Installing git..."
|
||||||
curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/.env.example" -o .env.example
|
if command -v apt-get &>/dev/null; then apt-get install -y git
|
||||||
curl -fsSL "https://raw.githubusercontent.com/nmemmert/budget/master/package.json" -o package.json
|
elif command -v dnf &>/dev/null; then dnf install -y git
|
||||||
|
else yum install -y git; fi
|
||||||
|
}
|
||||||
|
ok "git $(git --version | awk '{print $3}')"
|
||||||
|
|
||||||
# Note: Full source code still needs to be downloaded
|
# ── Clone / update ────────────────────────────────────────────────────────────
|
||||||
echo "⚠️ For full installation, Git is recommended"
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||||
echo "Downloading complete source archive..."
|
info "Updating existing installation at $INSTALL_DIR..."
|
||||||
curl -fsSL "https://github.com/nmemmert/budget/archive/refs/heads/master.tar.gz" | tar -xz --strip-components=1
|
git -C "$INSTALL_DIR" pull --ff-only
|
||||||
|
else
|
||||||
cd ..
|
info "Cloning to $INSTALL_DIR..."
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
fi
|
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"
|
cd "$INSTALL_DIR"
|
||||||
|
npm ci --prefer-offline 2>&1 | tail -3
|
||||||
|
npm run build 2>&1 | tail -5
|
||||||
|
ok "Build complete"
|
||||||
|
|
||||||
# Setup environment
|
# ── Data directory ────────────────────────────────────────────────────────────
|
||||||
echo ""
|
mkdir -p "$INSTALL_DIR/data"
|
||||||
echo "⚙️ Setting up environment..."
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
ok "Data directory: $INSTALL_DIR/data"
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
# ── Systemd service ───────────────────────────────────────────────────────────
|
||||||
echo "📝 Creating .env file..."
|
SERVICE_FILE="/etc/systemd/system/capsule.service"
|
||||||
cp .env.example .env
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Capsule Budget
|
||||||
|
After=network.target
|
||||||
|
|
||||||
# Generate encryption key
|
[Service]
|
||||||
echo "🔑 Generating encryption key..."
|
Type=simple
|
||||||
|
User=$SERVICE_USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
ExecStart=$(command -v node) node_modules/.bin/next start -p $PORT
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
|
||||||
if command -v openssl &> /dev/null; then
|
[Install]
|
||||||
# Use openssl if available
|
WantedBy=multi-user.target
|
||||||
ENCRYPTION_KEY=$(openssl rand -base64 32)
|
EOF
|
||||||
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)
|
systemctl daemon-reload
|
||||||
ESCAPED_KEY=$(echo "$ENCRYPTION_KEY" | sed 's/[&/\\]/\\&/g')
|
systemctl enable capsule
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
systemctl restart capsule
|
||||||
sed -i '' "s|change-this-to-a-secure-random-key|$ESCAPED_KEY|" .env
|
ok "systemd service 'capsule' enabled and started"
|
||||||
else
|
|
||||||
sed -i "s|change-this-to-a-secure-random-key|$ESCAPED_KEY|" .env
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
fi
|
sleep 2
|
||||||
echo "✅ Generated secure encryption key"
|
if systemctl is-active --quiet capsule; then
|
||||||
|
LOCAL_IP=$(hostname -I 2>/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
|
else
|
||||||
echo "ℹ️ .env file already exists, keeping existing configuration"
|
die "Service failed to start. Check logs: journalctl -u capsule -xe"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -10,7 +10,7 @@ interface SettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings({ user, onAccountDeleted }: 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
|
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
|
||||||
const [deleteInput, setDeleteInput] = useState('');
|
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 () => {
|
const handleClearData = async () => {
|
||||||
if (clearDataInput !== 'CLEAR ALL' || !showClearDataConfirm) return;
|
if (clearDataInput !== 'CLEAR ALL' || !showClearDataConfirm) return;
|
||||||
|
|
||||||
@@ -269,6 +332,7 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
|||||||
{[
|
{[
|
||||||
{ id: 'password', label: 'Password & Security' },
|
{ id: 'password', label: 'Password & Security' },
|
||||||
{ id: 'export', label: 'Data & Export' },
|
{ id: 'export', label: 'Data & Export' },
|
||||||
|
{ id: 'backup', label: 'Backup & Restore' },
|
||||||
{ id: 'delete', label: 'Account' },
|
{ id: 'delete', label: 'Account' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -501,6 +565,44 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
|||||||
</div>
|
</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 */}
|
{/* Delete Tab */}
|
||||||
{activeTab === 'delete' && (
|
{activeTab === 'delete' && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user