diff --git a/.gitignore b/.gitignore index e779903..f4670a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,14 @@ server/target/ server/chat.db server/chat.db-journal server/chat.db-wal +server/chat.db-shm +server/chat-dev.db +server/chat-dev.db-journal +server/chat-dev.db-wal +server/chat-dev.db-shm + +# Database backups +backups/ # Node client/node_modules/ diff --git a/client/vite.config.js b/client/vite.config.js index 52943c0..d76528b 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -18,21 +18,24 @@ function riotPlugin() { } } +const apiPort = process.env.VITE_API_PORT || '3001' +const clientPort = parseInt(process.env.VITE_PORT || '3000') + export default defineConfig({ plugins: [riotPlugin()], server: { - port: 3000, + port: clientPort, proxy: { '/api': { - target: 'http://localhost:3001', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, '/ws': { - target: 'ws://localhost:3001', + target: `ws://localhost:${apiPort}`, ws: true, }, '/uploads': { - target: 'http://localhost:3001', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, }, diff --git a/dev.sh b/dev.sh index 16a2bdb..f768996 100755 --- a/dev.sh +++ b/dev.sh @@ -1,12 +1,28 @@ #!/usr/bin/env bash # GroupChat2 Dev Script (macOS/Linux) # Runs both server (Rust/Axum) and client (Vite) with unified console output. +# Uses separate ports and database from production so both can run simultaneously. +# +# Production: server :3001, DB chat.db +# Development: server :3002, Vite client :3003, DB chat-dev.db +# # Ctrl+C stops both processes. set -e ROOT="$(cd "$(dirname "$0")" && pwd)" +# ─── Dev environment (separate from production) ───────────────────── +DEV_SERVER_PORT=3002 +DEV_CLIENT_PORT=3003 +DEV_DATABASE="chat-dev.db" + +export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc" +export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}" +export VITE_PORT="${DEV_CLIENT_PORT}" +export VITE_API_PORT="${DEV_SERVER_PORT}" +export RUST_LOG="${RUST_LOG:-info}" + # Colors MAGENTA='\033[0;35m' YELLOW='\033[0;33m' @@ -15,6 +31,16 @@ RED='\033[0;31m' GRAY='\033[0;90m' NC='\033[0m' +# Load .env for API keys (shared with production) +if [ -f "$ROOT/server/.env" ]; then + set -a + source "$ROOT/server/.env" + set +a + # Override DB and port with dev values (in case .env sets them) + export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc" + export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}" +fi + # Install client deps if needed if [ ! -d "$ROOT/client/node_modules" ]; then echo -e "${CYAN}[dev] Installing client dependencies...${NC}" @@ -23,8 +49,8 @@ fi echo "" echo -e " ${MAGENTA}GroupChat2 Dev Server${NC}" -echo -e " ${YELLOW}Server: http://localhost:3001${NC}" -echo -e " ${CYAN}Client: http://localhost:3000${NC}" +echo -e " ${YELLOW}Server: http://localhost:${DEV_SERVER_PORT}${NC} ${GRAY}(DB: ${DEV_DATABASE})${NC}" +echo -e " ${CYAN}Client: http://localhost:${DEV_CLIENT_PORT}${NC}" echo -e " ${GRAY}Press Ctrl+C to stop both${NC}" echo "" @@ -40,12 +66,9 @@ cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null [ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null - # Kill any lingering processes on ports 3000/3001 - lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true - lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true - - # Kill groupchat-server if still running - pkill -f "groupchat-server" 2>/dev/null || true + # Kill any lingering processes on dev ports + lsof -ti:${DEV_CLIENT_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true + lsof -ti:${DEV_SERVER_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true echo -e "${RED}[dev] Stopped.${NC}" exit 0 @@ -53,13 +76,13 @@ cleanup() { trap cleanup INT TERM -# Start server (Rust/Axum) +# Start server (Rust/Axum) on dev port with dev database (cd "$ROOT/server" && cargo run 2>&1 | while IFS= read -r line; do [ -n "$line" ] && echo -e "${YELLOW}[server]${NC} $line" done) & SERVER_PID=$! -# Start client (Vite) +# Start client (Vite) on dev port, proxying to dev server (cd "$ROOT/client" && npm run dev 2>&1 | while IFS= read -r line; do [ -n "$line" ] && echo -e "${CYAN}[client]${NC} $line" done) & diff --git a/server/src/main.rs b/server/src/main.rs index 663c976..8d0c5bf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,123 @@ use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +/// Extract the file path from a SQLite DATABASE_URL like "sqlite:chat.db?mode=rwc" +fn db_file_path(database_url: &str) -> Option { + let path = database_url.strip_prefix("sqlite:")?; + // Strip query params like ?mode=rwc + let path = path.split('?').next().unwrap_or(path); + Some(path.to_string()) +} + +/// Create a timestamped backup of the SQLite database file. +/// Backups are stored in a `backups/` directory next to the db file. +/// Only keeps the 10 most recent backups to avoid unbounded disk usage. +fn backup_database(database_url: &str) { + let Some(db_path) = db_file_path(database_url) else { + tracing::warn!("Could not parse database path from URL, skipping backup"); + return; + }; + + let db_file = std::path::Path::new(&db_path); + if !db_file.exists() { + tracing::info!("Database file does not exist yet, skipping backup"); + return; + } + + // Create backups directory next to the database + let backup_dir = db_file + .parent() + .unwrap_or(std::path::Path::new(".")) + .join("backups"); + + if let Err(e) = std::fs::create_dir_all(&backup_dir) { + tracing::error!("Failed to create backup directory: {}", e); + return; + } + + // Build timestamped backup filename: chat.db -> chat_2026-03-09_143022.db + let stem = db_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("db"); + let ext = db_file + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("db"); + + let now = chrono::Local::now(); + let backup_name = format!("{}_{}.{}", stem, now.format("%Y-%m-%d_%H%M%S"), ext); + let backup_path = backup_dir.join(&backup_name); + + match std::fs::copy(db_file, &backup_path) { + Ok(bytes) => { + tracing::info!( + "Database backup created: {} ({:.1} KB)", + backup_path.display(), + bytes as f64 / 1024.0 + ); + } + Err(e) => { + tracing::error!("Failed to backup database: {}", e); + return; + } + } + + // Also copy WAL and SHM files if they exist (for consistency) + let wal_path = format!("{}-wal", db_path); + let shm_path = format!("{}-shm", db_path); + if std::path::Path::new(&wal_path).exists() { + let wal_backup = backup_dir.join(format!("{}_{}.{}-wal", stem, now.format("%Y-%m-%d_%H%M%S"), ext)); + let _ = std::fs::copy(&wal_path, &wal_backup); + } + if std::path::Path::new(&shm_path).exists() { + let shm_backup = backup_dir.join(format!("{}_{}.{}-shm", stem, now.format("%Y-%m-%d_%H%M%S"), ext)); + let _ = std::fs::copy(&shm_path, &shm_backup); + } + + // Prune old backups: keep only the 10 most recent + prune_old_backups(&backup_dir, stem, 10); +} + +/// Remove old backups, keeping only the `keep` most recent ones. +fn prune_old_backups(backup_dir: &std::path::Path, stem: &str, keep: usize) { + let prefix = format!("{}_", stem); + let mut backups: Vec<_> = std::fs::read_dir(backup_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + // Match main db backups (not -wal/-shm) + name.starts_with(&prefix) && !name.ends_with("-wal") && !name.ends_with("-shm") + }) + .collect(); + + if backups.len() <= keep { + return; + } + + // Sort by filename (timestamps sort lexicographically) + backups.sort_by_key(|e| e.file_name()); + + let to_remove = backups.len() - keep; + for entry in backups.into_iter().take(to_remove) { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("Failed to remove old backup {}: {}", name, e); + } else { + tracing::debug!("Pruned old backup: {}", name); + // Also remove associated WAL/SHM backups + let wal = path.with_extension(format!("{}-wal", path.extension().unwrap_or_default().to_string_lossy())); + let shm = path.with_extension(format!("{}-shm", path.extension().unwrap_or_default().to_string_lossy())); + let _ = std::fs::remove_file(&wal); + let _ = std::fs::remove_file(&shm); + } + } +} + pub struct AppState { pub db: sqlx::SqlitePool, pub jwt_secret: String, @@ -38,6 +155,9 @@ async fn main() { let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set"); let brave_api_key = std::env::var("BRAVE_API_KEY").expect("BRAVE_API_KEY must be set"); + // Backup the database before connecting and running migrations + backup_database(&database_url); + let db = SqlitePoolOptions::new() .max_connections(5) .connect(&database_url)