const express = require('express'); const axios = require('axios'); const cheerio = require('cheerio'); const cors = require('cors'); const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const puppeteer = require('puppeteer-extra'); const StealthPlugin = require('puppeteer-extra-plugin-stealth'); const Database = require('better-sqlite3'); const rateLimit = require('express-rate-limit'); // Enable stealth mode to bypass bot detection (Cloudflare, Akamai, etc.) puppeteer.use(StealthPlugin()); const app = express(); const PORT = process.env.PORT || 3000; // Setup directories for persistence const DATA_DIR = path.join(__dirname, 'data'); const DB_FILE = path.join(DATA_DIR, 'forkfolio.db'); if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Open SQLite Database connection and set WAL journal mode for concurrency speed const db = new Database(DB_FILE); db.pragma('journal_mode = WAL'); // Initialize database schema tables db.prepare(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, salt TEXT NOT NULL, passwordHash TEXT NOT NULL, isAdmin INTEGER NOT NULL DEFAULT 0, isRecipeManager INTEGER NOT NULL DEFAULT 0, defaultConsolidateLists INTEGER NOT NULL DEFAULT 1, createdAt TEXT NOT NULL ) `).run(); // Ensure defaultConsolidateLists column exists on existing users table try { const userCols = db.pragma('table_info(users)'); if (!userCols.some(c => c.name === 'defaultConsolidateLists')) { db.prepare('ALTER TABLE users ADD COLUMN defaultConsolidateLists INTEGER NOT NULL DEFAULT 1').run(); } } catch (colErr) { console.error('Failed to check/add defaultConsolidateLists column:', colErr); } db.prepare(` CREATE TABLE IF NOT EXISTS recipes ( id TEXT PRIMARY KEY, userId TEXT NOT NULL, createdBy TEXT NOT NULL, isPublic INTEGER NOT NULL DEFAULT 1, title TEXT NOT NULL, description TEXT, servings TEXT, prepTime TEXT, cookTime TEXT, totalTime TEXT, image TEXT, images TEXT, -- JSON stringified array ingredients TEXT, -- JSON stringified array instructions TEXT, -- JSON stringified array sourceUrl TEXT, shareToken TEXT UNIQUE, -- Unguessable sharing token createdAt TEXT NOT NULL, updatedAt TEXT, FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS settings ( geminiApiKey TEXT DEFAULT '' ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS recipe_shares_visits ( shareToken TEXT NOT NULL, visitorIp TEXT NOT NULL, visitedAt TEXT NOT NULL, PRIMARY KEY (shareToken, visitorIp) ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS shopping_lists ( id TEXT PRIMARY KEY, userId TEXT NOT NULL, createdBy TEXT NOT NULL, name TEXT NOT NULL, shareToken TEXT UNIQUE, createdAt TEXT NOT NULL, updatedAt TEXT, FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS shopping_list_items ( id TEXT PRIMARY KEY, listId TEXT NOT NULL, rawText TEXT NOT NULL, itemName TEXT NOT NULL, quantity REAL, unit TEXT DEFAULT '', category TEXT NOT NULL DEFAULT 'Other', recipeId TEXT, recipeTitle TEXT, checked INTEGER NOT NULL DEFAULT 0, createdAt TEXT NOT NULL, FOREIGN KEY (listId) REFERENCES shopping_lists (id) ON DELETE CASCADE ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS shopping_list_shares_visits ( shareToken TEXT NOT NULL, visitorIp TEXT NOT NULL, visitedAt TEXT NOT NULL, PRIMARY KEY (shareToken, visitorIp) ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS backup_config ( id INTEGER PRIMARY KEY CHECK (id = 1), scheduleFrequency TEXT NOT NULL DEFAULT 'off', scheduleTime TEXT NOT NULL DEFAULT '02:00', scheduleDay INTEGER NOT NULL DEFAULT 0, retentionCount INTEGER NOT NULL DEFAULT 14, backupFormat TEXT NOT NULL DEFAULT 'both', embedRemoteImages INTEGER NOT NULL DEFAULT 1, s3Enabled INTEGER NOT NULL DEFAULT 0, s3Endpoint TEXT NOT NULL DEFAULT 'https://s3.us-east-1.wasabisys.com', s3Region TEXT NOT NULL DEFAULT 'us-east-1', s3Bucket TEXT NOT NULL DEFAULT '', s3Prefix TEXT NOT NULL DEFAULT 'forkfolio-backups/', s3AccessKeyId TEXT NOT NULL DEFAULT '', s3SecretAccessKey TEXT NOT NULL DEFAULT '', gdriveEnabled INTEGER NOT NULL DEFAULT 0, gdriveClientId TEXT NOT NULL DEFAULT '', gdriveClientSecret TEXT NOT NULL DEFAULT '', gdriveRefreshToken TEXT NOT NULL DEFAULT '', gdriveFolderId TEXT NOT NULL DEFAULT '', lastBackupAt TEXT, lastBackupStatus TEXT, lastBackupMessage TEXT ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS backup_history ( id TEXT PRIMARY KEY, label TEXT NOT NULL, triggerType TEXT NOT NULL, sizeBytes INTEGER NOT NULL DEFAULT 0, userCount INTEGER NOT NULL DEFAULT 0, recipeCount INTEGER NOT NULL DEFAULT 0, imageCount INTEGER NOT NULL DEFAULT 0, listCount INTEGER NOT NULL DEFAULT 0, s3Objects TEXT DEFAULT '[]', gdriveFiles TEXT DEFAULT '[]', status TEXT NOT NULL, errorDetails TEXT, createdAt TEXT NOT NULL ) `).run(); const backupConfigCount = db.prepare('SELECT COUNT(*) as count FROM backup_config').get().count; if (backupConfigCount === 0) { db.prepare('INSERT INTO backup_config (id) VALUES (1)').run(); } try { db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveAccountEmail TEXT NOT NULL DEFAULT ''").run(); } catch (e) {} try { db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveFolderName TEXT NOT NULL DEFAULT 'ForkFolio Backups'").run(); } catch (e) {} // Initialize single settings record if empty const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; if (settingsCount === 0) { db.prepare('INSERT INTO settings (geminiApiKey) VALUES (?)').run(''); } // Ensure default system_admin and admin user profiles exist try { // Ensure default system_admin user exists to fulfill recipe foreign key constraints const hasSystemAdmin = db.prepare("SELECT COUNT(*) as count FROM users WHERE id = 'system_admin'").get().count; if (hasSystemAdmin === 0) { db.prepare(` INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?) `).run('system_admin', 'system_admin', 'system_salt', 'system_hash', 0, 0, new Date().toISOString()); } const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE isAdmin = 1").get().count; if (adminCount === 0) { const salt = crypto.randomBytes(16).toString('hex'); const passwordHash = crypto.pbkdf2Sync('admin123', salt, 1000, 64, 'sha512').toString('hex'); db.prepare(` INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( 'u_admin', 'admin', salt, passwordHash, 1, 1, new Date().toISOString() ); console.log('--- ForkFolio Admin Initialized ---'); console.log('Default Admin Username: admin'); console.log('Default Admin Password: admin123'); console.log('--------------------------------------'); } } catch (e) { console.error('Failed to initialize default admin and system users:', e); } // Automatic Migration from legacy JSON databases if they exist const LEGACY_USERS_FILE = path.join(DATA_DIR, 'users.json'); const LEGACY_RECIPES_FILE = path.join(DATA_DIR, 'recipes.json'); const LEGACY_SETTINGS_FILE = path.join(DATA_DIR, 'settings.json'); try { // Temporarily disable foreign keys during bulk migrations to allow flexible insert order db.pragma('foreign_keys = OFF'); // 1. Migrate Users if (fs.existsSync(LEGACY_USERS_FILE)) { const backupUsersFile = path.join(DATA_DIR, 'users.json.backup'); const data = fs.readFileSync(LEGACY_USERS_FILE, 'utf8'); const users = JSON.parse(data); if (Array.isArray(users) && users.length > 0) { console.log(`[Migration] Found ${users.length} legacy users. Migrating to SQLite...`); const insertUser = db.prepare(` INSERT OR IGNORE INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?) `); const transaction = db.transaction((userList) => { for (const u of userList) { insertUser.run( u.id, u.username.trim().toLowerCase(), u.salt, u.passwordHash, u.isAdmin ? 1 : 0, u.isRecipeManager ? 1 : 0, u.createdAt || new Date().toISOString() ); } }); transaction(users); console.log(`[Migration] User migration completed.`); } fs.renameSync(LEGACY_USERS_FILE, backupUsersFile); } // 2. Migrate Recipes if (fs.existsSync(LEGACY_RECIPES_FILE)) { const backupRecipesFile = path.join(DATA_DIR, 'recipes.json.backup'); const data = fs.readFileSync(LEGACY_RECIPES_FILE, 'utf8'); const recipes = JSON.parse(data); if (Array.isArray(recipes) && recipes.length > 0) { console.log(`[Migration] Found ${recipes.length} legacy recipes. Migrating to SQLite...`); const insertRecipe = db.prepare(` INSERT OR IGNORE INTO recipes ( id, userId, createdBy, isPublic, title, description, servings, prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const transaction = db.transaction((recipeList) => { for (const r of recipeList) { const shareToken = r.isPublic !== false && !r.shareToken ? crypto.randomBytes(16).toString('hex') : (r.shareToken || null); insertRecipe.run( r.id, r.userId || 'system_admin', r.createdBy || 'gourmet_system', r.isPublic !== false ? 1 : 0, r.title || 'Untitled Recipe', r.description || '', r.servings || '', r.prepTime || '', r.cookTime || '', r.totalTime || '', r.image || '', JSON.stringify(r.images || []), JSON.stringify(r.ingredients || []), JSON.stringify(r.instructions || []), r.sourceUrl || '', shareToken, r.createdAt || new Date().toISOString(), r.updatedAt || null ); } }); transaction(recipes); console.log(`[Migration] Recipe migration completed.`); } fs.renameSync(LEGACY_RECIPES_FILE, backupRecipesFile); } // 3. Migrate Settings if (fs.existsSync(LEGACY_SETTINGS_FILE)) { const backupSettingsFile = path.join(DATA_DIR, 'settings.json.backup'); const data = fs.readFileSync(LEGACY_SETTINGS_FILE, 'utf8'); const settings = JSON.parse(data); if (settings && settings.geminiApiKey) { console.log('[Migration] Migrating system settings key to SQLite...'); db.prepare('UPDATE settings SET geminiApiKey = ?').run(settings.geminiApiKey.trim()); console.log('[Migration] Settings migration completed.'); } fs.renameSync(LEGACY_SETTINGS_FILE, backupSettingsFile); } // Restore foreign keys enforcement after migrations db.pragma('foreign_keys = ON'); } catch (migrationError) { // Guarantee foreign keys re-enabled even on crash db.pragma('foreign_keys = ON'); console.error('[Migration] Failed to perform automatic SQLite migration:', migrationError); } // In-memory sessions storage (token -> { id, username, isAdmin, isRecipeManager }) const activeSessions = new Map(); app.use(cors()); app.use(express.json({ limit: '100mb' })); app.use(express.urlencoded({ limit: '100mb', extended: true })); app.use(express.static(path.join(__dirname, 'public'))); // Configure rate-limiting middleware for public shared routes const publicShareLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per 15-minute window message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' } }); // Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins") function parseISODuration(duration) { if (!duration || typeof duration !== 'string') return ''; const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return duration; const hours = match[1] ? parseInt(match[1], 10) : 0; const minutes = match[2] ? parseInt(match[2], 10) : 0; let result = ''; if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `; if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`; return result.trim() || duration; } // Helper: Extract recipe instructions from schema format function extractInstructions(instructions) { if (!instructions) return []; if (typeof instructions === 'string') { return instructions.split(/\r?\n/).map(s => s.trim()).filter(Boolean); } if (Array.isArray(instructions)) { const result = []; for (const inst of instructions) { if (typeof inst === 'string') { result.push(inst.trim()); } else if (inst && typeof inst === 'object') { if (inst['@type'] === 'HowToStep') { result.push(inst.text || inst.name || ''); } else if (inst['@type'] === 'HowToSection' && Array.isArray(inst.itemListElement)) { if (inst.name) { result.push(`### ${inst.name}`); // Prefix section names } for (const step of inst.itemListElement) { if (typeof step === 'string') { result.push(step.trim()); } else if (step && typeof step === 'object') { result.push(step.text || step.name || ''); } } } else if (inst.text) { result.push(inst.text); } else if (inst.name) { result.push(inst.name); } } } return result.filter(Boolean); } return []; } // Helper: Normalize image resources from JSON-LD schema function normalizeImages(imageObj) { if (!imageObj) return []; if (typeof imageObj === 'string') return [imageObj]; if (Array.isArray(imageObj)) { return imageObj.map(img => (typeof img === 'string' ? img : img.url)).filter(Boolean); } if (typeof imageObj === 'object' && imageObj.url) { return [imageObj.url]; } return []; } // Password Hashing and Salt helpers function generateSalt() { return crypto.randomBytes(16).toString('hex'); } function hashPassword(password, salt) { return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex'); } // Middleware: Authenticate Session Token function requireAuth(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Authorization required. Please log in.' }); } const token = authHeader.substring(7); const session = activeSessions.get(token); if (!session) { return res.status(401).json({ error: 'Session expired. Please log in again.' }); } req.user = session; next(); } // Helper: Deserialize JSON columns from SQLite recipe row function formatRecipe(row) { if (!row) return null; let visitorCount = 0; if (row.shareToken) { try { const res = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(row.shareToken); visitorCount = res ? res.count : 0; } catch (e) { console.error('Failed to query unique visitor count:', e); } } return { ...row, isPublic: row.isPublic === 1, images: JSON.parse(row.images || '[]'), ingredients: JSON.parse(row.ingredients || '[]'), instructions: JSON.parse(row.instructions || '[]'), visitorCount }; } // Auth API: User Registration (Requires Admin session) app.post('/api/auth/register', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required.' }); } const cleanUsername = username.trim().toLowerCase(); if (!/^[a-z0-9_]{3,20}$/.test(cleanUsername)) { return res.status(400).json({ error: 'Username must be 3-20 characters long and contain only alphanumeric characters or underscores.' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters long.' }); } try { const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(cleanUsername); if (existing) { return res.status(400).json({ error: 'Username is already taken.' }); } const salt = generateSalt(); const passwordHash = hashPassword(password, salt); const id = 'u_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); db.prepare(` INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(id, cleanUsername, salt, passwordHash, 0, 0, new Date().toISOString()); res.status(201).json({ user: { id, username: cleanUsername, isAdmin: false, isRecipeManager: false, defaultConsolidateLists: true } }); } catch (error) { console.error('Error during registration:', error); res.status(500).json({ error: 'Failed to create account.' }); } }); // Auth API: User Login app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required.' }); } const cleanUsername = username.trim().toLowerCase(); try { const user = db.prepare('SELECT * FROM users WHERE username = ?').get(cleanUsername); if (!user) { return res.status(401).json({ error: 'Invalid username or password.' }); } const hash = hashPassword(password, user.salt); if (hash !== user.passwordHash) { return res.status(401).json({ error: 'Invalid username or password.' }); } const token = crypto.randomBytes(32).toString('hex'); const defaultConsolidateLists = user.defaultConsolidateLists !== 0; activeSessions.set(token, { id: user.id, username: user.username, isAdmin: user.isAdmin === 1, isRecipeManager: user.isRecipeManager === 1, defaultConsolidateLists }); res.json({ token, user: { id: user.id, username: user.username, isAdmin: user.isAdmin === 1, isRecipeManager: user.isRecipeManager === 1, defaultConsolidateLists } }); } catch (error) { console.error('Error during login:', error); res.status(500).json({ error: 'Login failed.' }); } }); // Admin API: List all users (Requires Admin session) app.get('/api/admin/users', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } try { const rows = db.prepare('SELECT id, username, isAdmin, isRecipeManager, createdAt FROM users').all(); const users = rows.map(u => ({ id: u.id, username: u.username, isAdmin: u.isAdmin === 1, isRecipeManager: u.isRecipeManager === 1, createdAt: u.createdAt })); res.json(users); } catch (error) { console.error('Error listing users:', error); res.status(500).json({ error: 'Failed to retrieve registered users.' }); } }); // Admin API: Delete user (Requires Admin session) app.delete('/api/admin/users/:id', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } if (req.params.id === req.user.id) { return res.status(400).json({ error: 'You cannot delete your own administrative account.' }); } try { const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); if (!user) { return res.status(404).json({ error: 'User not found.' }); } const preserveRecipes = req.body.preserveRecipes === true; // SQLite transaction for relational database integrity const performDelete = db.transaction(() => { if (preserveRecipes) { // Reassign user's recipes to system_admin, set as public db.prepare(` UPDATE recipes SET userId = 'system_admin', createdBy = ?, isPublic = 1 WHERE userId = ? `).run(`${user.username} (former)`, user.id); } else { // Delete user's recipes db.prepare('DELETE FROM recipes WHERE userId = ?').run(user.id); } // Delete user profile db.prepare('DELETE FROM users WHERE id = ?').run(user.id); }); performDelete(); // Invalidate active sessions for (const [token, session] of activeSessions.entries()) { if (session.id === user.id) { activeSessions.delete(token); } } res.json({ success: true }); } catch (error) { console.error('Error deleting user:', error); res.status(500).json({ error: 'Failed to delete user.' }); } }); // Admin API: Change user role (Requires Admin session) app.put('/api/admin/users/:id/role', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } if (req.params.id === req.user.id) { return res.status(400).json({ error: 'You cannot change your own administrator role privileges.' }); } const { isAdmin } = req.body; if (typeof isAdmin !== 'boolean') { return res.status(400).json({ error: 'isAdmin property must be a boolean.' }); } try { const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); if (!user) { return res.status(404).json({ error: 'User not found.' }); } db.prepare('UPDATE users SET isAdmin = ? WHERE id = ?').run(isAdmin ? 1 : 0, user.id); // Update sessions for (const [token, session] of activeSessions.entries()) { if (session.id === user.id) { session.isAdmin = isAdmin; activeSessions.set(token, session); } } res.json({ success: true, user: { id: user.id, username: user.username, isAdmin } }); } catch (error) { console.error('Error changing user role:', error); res.status(500).json({ error: 'Failed to update user role.' }); } }); // Admin API: Toggle recipe manager role (Requires Admin session) app.put('/api/admin/users/:id/recipe-manager', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } const { isRecipeManager } = req.body; if (typeof isRecipeManager !== 'boolean') { return res.status(400).json({ error: 'isRecipeManager property must be a boolean.' }); } try { const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); if (!user) { return res.status(404).json({ error: 'User not found.' }); } db.prepare('UPDATE users SET isRecipeManager = ? WHERE id = ?').run(isRecipeManager ? 1 : 0, user.id); // Update sessions for (const [token, session] of activeSessions.entries()) { if (session.id === user.id) { session.isRecipeManager = isRecipeManager; activeSessions.set(token, session); } } res.json({ success: true, user: { id: user.id, username: user.username, isRecipeManager } }); } catch (error) { console.error('Error toggling manager role:', error); res.status(500).json({ error: 'Failed to update manager permission.' }); } }); // Admin API: List all public recipe shares and visitor stats (Requires Admin session) app.get('/api/admin/shared-recipes', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } try { const rows = db.prepare(` SELECT id, title, createdBy, shareToken, createdAt FROM recipes WHERE isPublic = 1 AND shareToken IS NOT NULL ORDER BY createdAt DESC `).all(); const sharedRecipes = rows.map(r => { let visitorCount = 0; try { const stats = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(r.shareToken); visitorCount = stats ? stats.count : 0; } catch (err) { console.error('Failed to query shared recipe visitor count:', err); } return { id: r.id, title: r.title, createdBy: r.createdBy, shareToken: r.shareToken, createdAt: r.createdAt, visitorCount }; }); res.json(sharedRecipes); } catch (error) { console.error('Error fetching admin shared recipes:', error); res.status(500).json({ error: 'Failed to retrieve shared recipes.' }); } }); // API: List authenticated user's private recipes app.get('/api/recipes', requireAuth, (req, res) => { try { const rows = db.prepare('SELECT * FROM recipes WHERE userId = ? ORDER BY createdAt DESC').all(req.user.id); const userRecipes = rows.map(formatRecipe); res.json(userRecipes); } catch (error) { console.error('Error fetching recipes:', error); res.status(500).json({ error: 'Failed to retrieve recipes.' }); } }); // API: List all public recipes for Shared Cookbook app.get('/api/recipes/public', requireAuth, (req, res) => { try { const rows = db.prepare('SELECT * FROM recipes WHERE isPublic = 1 ORDER BY createdAt DESC').all(); const publicRecipes = rows.map(formatRecipe); res.json(publicRecipes); } catch (error) { console.error('Error fetching public recipes:', error); res.status(500).json({ error: 'Failed to retrieve community recipes.' }); } }); // API: Clone a public recipe to user's private collection app.post('/api/recipes/clone/:id', requireAuth, (req, res) => { try { const sourceRecipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); if (!sourceRecipe) { return res.status(404).json({ error: 'Recipe not found' }); } if (sourceRecipe.isPublic === 0) { return res.status(403).json({ error: 'This recipe is private and cannot be cloned.' }); } const clonedId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); db.prepare(` INSERT INTO recipes ( id, userId, createdBy, isPublic, title, description, servings, prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( clonedId, req.user.id, req.user.username, 0, // Default cloned copies to private so users can customize them privately sourceRecipe.title || 'Untitled Recipe', sourceRecipe.description || '', sourceRecipe.servings || '', sourceRecipe.prepTime || '', sourceRecipe.cookTime || '', sourceRecipe.totalTime || '', sourceRecipe.image || '', sourceRecipe.images || '[]', sourceRecipe.ingredients || '[]', sourceRecipe.instructions || '[]', sourceRecipe.sourceUrl || '', null, // Cloned copies start as private, no share token new Date().toISOString(), null ); const clonedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(clonedId)); res.status(201).json(clonedObj); } catch (error) { console.error('Error cloning recipe:', error); res.status(500).json({ error: 'Failed to clone recipe.' }); } }); // API: Save new recipe app.post('/api/recipes', requireAuth, (req, res) => { const isPublic = req.body.isPublic !== false ? 1 : 0; const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null; const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); try { db.prepare(` INSERT INTO recipes ( id, userId, createdBy, isPublic, title, description, servings, prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( newId, req.user.id, req.user.username, isPublic, req.body.title || 'Untitled Recipe', req.body.description || '', req.body.servings || '', req.body.prepTime || '', req.body.cookTime || '', req.body.totalTime || '', req.body.image || '', JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []), JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []), JSON.stringify(Array.isArray(req.body.instructions) ? req.body.instructions : []), req.body.sourceUrl || '', shareToken, new Date().toISOString(), null ); const savedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(newId)); res.status(201).json(savedObj); } catch (error) { console.error('Error saving new recipe:', error); res.status(500).json({ error: 'Failed to save recipe.' }); } }); // API: Update recipe app.put('/api/recipes/:id', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); if (!existing) { return res.status(404).json({ error: 'Recipe not found' }); } if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { return res.status(403).json({ error: 'You do not have permission to modify this recipe.' }); } const isPublic = req.body.isPublic !== undefined ? (req.body.isPublic ? 1 : 0) : existing.isPublic; // Generate shareToken if it becomes public and doesn't have one let shareToken = existing.shareToken; if (isPublic === 1 && !shareToken) { shareToken = crypto.randomBytes(16).toString('hex'); } else if (isPublic === 0) { shareToken = null; // Revoke shareToken if it is made private } db.prepare(` UPDATE recipes SET title = ?, description = ?, ingredients = ?, instructions = ?, servings = ?, prepTime = ?, cookTime = ?, totalTime = ?, image = ?, images = ?, sourceUrl = ?, isPublic = ?, shareToken = ?, updatedAt = ? WHERE id = ? `).run( req.body.title || existing.title, req.body.description !== undefined ? req.body.description : existing.description, Array.isArray(req.body.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients, Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions, req.body.servings !== undefined ? req.body.servings : existing.servings, req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime, req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime, req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime, req.body.image !== undefined ? req.body.image : existing.image, Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images, req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl, isPublic, shareToken, new Date().toISOString(), req.params.id ); const updatedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id)); res.json(updatedObj); } catch (error) { console.error('Error updating recipe:', error); res.status(500).json({ error: 'Failed to update recipe.' }); } }); // API: Delete recipe app.delete('/api/recipes/:id', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT userId FROM recipes WHERE id = ?').get(req.params.id); if (!existing) { return res.status(404).json({ error: 'Recipe not found' }); } if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { return res.status(403).json({ error: 'You do not have permission to delete this recipe.' }); } db.prepare('DELETE FROM recipes WHERE id = ?').run(req.params.id); res.json({ success: true }); } catch (error) { console.error('Error deleting recipe:', error); res.status(500).json({ error: 'Failed to delete recipe.' }); } }); // Helper: Parse recipe data from raw HTML string function parseRecipeFromHtml(html, url) { const $ = cheerio.load(html); // Extract JSON-LD structures let schemaRecipe = null; $('script[type="application/ld+json"]').each((i, el) => { try { const text = $(el).html(); if (!text) return; const data = JSON.parse(text); const searchForRecipe = (obj) => { if (!obj || typeof obj !== 'object') return; if (Array.isArray(obj)) { for (const item of obj) { searchForRecipe(item); if (schemaRecipe) return; } } else { const type = obj['@type']; if (type === 'Recipe' || (Array.isArray(type) && type.includes('Recipe'))) { schemaRecipe = obj; return; } for (const key in obj) { if (obj.hasOwnProperty(key)) { searchForRecipe(obj[key]); if (schemaRecipe) return; } } } }; searchForRecipe(data); } catch (e) { // Safe to ignore malformed JSON script elements } }); // Populate structured data object let recipeData = { title: '', description: '', ingredients: [], instructions: [], servings: '', prepTime: '', cookTime: '', totalTime: '', images: [], sourceUrl: url }; // Scrape candidate image arrays from tags to enable user choice const extractedImages = []; $('img').each((i, el) => { const src = $(el).attr('src') || $(el).attr('data-src') || $(el).attr('data-lazy-src'); if (src && src.startsWith('http')) { const width = $(el).attr('width'); const height = $(el).attr('height'); const isSmall = (width && parseInt(width, 10) < 150) || (height && parseInt(height, 10) < 150); const containsTrashKeywords = /avatar|logo|icon|spacer|ad-|banner|pixel|tracking/i.test(src); if (!isSmall && !containsTrashKeywords && !extractedImages.includes(src)) { extractedImages.push(src); } } }); if (schemaRecipe) { // Standard schema-based extraction recipeData.title = schemaRecipe.name || $('title').text().trim() || ''; recipeData.description = schemaRecipe.description || ''; if (schemaRecipe.recipeYield) { recipeData.servings = Array.isArray(schemaRecipe.recipeYield) ? schemaRecipe.recipeYield[0] : schemaRecipe.recipeYield.toString(); } recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || ''); recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || ''); recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || ''); const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || []; if (typeof rawIngredients === 'string') { recipeData.ingredients = rawIngredients.split(/\r?\n/).map(i => i.trim()).filter(Boolean); } else if (Array.isArray(rawIngredients)) { recipeData.ingredients = rawIngredients; } recipeData.instructions = extractInstructions(schemaRecipe.recipeInstructions || schemaRecipe.recipeInstruction); const schemaImages = normalizeImages(schemaRecipe.image); schemaImages.forEach(img => { if (!extractedImages.includes(img)) { extractedImages.unshift(img); } }); } else { // Fallback Scraping strategies if JSON-LD is missing recipeData.title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || ''; recipeData.description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || ''; const ogImg = $('meta[property="og:image"]').attr('content'); if (ogImg && !extractedImages.includes(ogImg)) { extractedImages.unshift(ogImg); } // Parse ingredients structurally const ingredientSelectors = [ '.wprm-recipe-ingredient', '.tasty-recipes-ingredients li', '.tasty-recipe-ingredients li', '.recipe-ingredients li', '.ingredients-list li', '.ingredients li', '[class*="ingredients"] li', '[class*="ingredients"] p', '[class*="ingredient"] li', '[class*="ingredient"] p', 'ul[class*="ingredient"] li', 'ol[class*="ingredient"] li', '.cooked-ingredient', '.recipe-ingredients-wrap li' ]; for (const selector of ingredientSelectors) { const items = $(selector); if (items.length > 1) { items.each((i, el) => { const txt = $(el).text().trim().replace(/\s+/g, ' '); if (txt && !recipeData.ingredients.includes(txt)) { recipeData.ingredients.push(txt); } }); if (recipeData.ingredients.length > 1) break; } } // Parse instructions structurally const instructionSelectors = [ '.wprm-recipe-instruction', '.tasty-recipes-instructions li', '.tasty-recipe-instructions li', '.recipe-directions li', '.recipe-instructions li', '.instructions-list li', '.instructions li', '.recipe-steps li', '[class*="instructions"] li', '[class*="instructions"] p', '[class*="directions"] li', '[class*="directions"] p', '[class*="step"] li', '[class*="step"] p', '.cooked-step', '.recipe-method li', '.recipe-method p', '.recipe-preparation li' ]; for (const selector of instructionSelectors) { const items = $(selector); if (items.length > 1) { items.each((i, el) => { const txt = $(el).text().trim().replace(/\s+/g, ' '); if (txt && !recipeData.instructions.includes(txt)) { recipeData.instructions.push(txt); } }); if (recipeData.instructions.length > 1) break; } } } // Heuristics Fallback: Heading Sibling crawl if still empty if (recipeData.ingredients.length === 0) { $('h1, h2, h3, h4, h5').each((i, heading) => { const text = $(heading).text().toLowerCase(); if (text.includes('ingredients') && !text.includes('instruction') && !text.includes('direction')) { let next = $(heading).next(); if (next.length === 0) { next = $(heading).parent().next(); } let limit = 0; while (next.length > 0 && limit < 15) { if (/h[1-6]/i.test(next[0].tagName)) break; const items = next.find('li, p'); if (items.length > 0) { items.each((j, el) => { const itemText = $(el).text().trim().replace(/\s+/g, ' '); if (itemText && itemText.length > 2 && itemText.length < 200 && !recipeData.ingredients.includes(itemText)) { recipeData.ingredients.push(itemText); } }); } else { const itemText = next.text().trim().replace(/\s+/g, ' '); if (itemText && itemText.length > 2 && itemText.length < 200) { recipeData.ingredients.push(itemText); } } next = next.next(); limit++; } if (recipeData.ingredients.length > 2) return false; } }); } if (recipeData.instructions.length === 0) { $('h1, h2, h3, h4, h5').each((i, heading) => { const text = $(heading).text().toLowerCase(); if (/instruction|direction|method|preparation|steps/i.test(text) && !text.includes('ingredient')) { let next = $(heading).next(); if (next.length === 0) { next = $(heading).parent().next(); } let limit = 0; while (next.length > 0 && limit < 15) { if (/h[1-6]/i.test(next[0].tagName)) break; const items = next.find('li, p'); if (items.length > 0) { items.each((j, el) => { const itemText = $(el).text().trim().replace(/\s+/g, ' '); if (itemText && itemText.length > 5 && itemText.length < 1000 && !recipeData.instructions.includes(itemText)) { recipeData.instructions.push(itemText); } }); } else { const itemText = next.text().trim().replace(/\s+/g, ' '); if (itemText && itemText.length > 5 && itemText.length < 1000) { recipeData.instructions.push(itemText); } } next = next.next(); limit++; } if (recipeData.instructions.length > 1) return false; } }); } // Heuristics Fallback: Blockquote / Multi-paragraph text classifier (for vintage/informal blogs) if (recipeData.ingredients.length === 0 && recipeData.instructions.length === 0) { const isStepText = (text) => { if (/^(?:step\s*)?\d+[\.\\)\-]\s/i.test(text)) return true; if (/^\d+\s+(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text)) return true; if (/^(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text) && text.length > 25) return true; return false; }; const isTitleOrHeader = (text) => { const titleClean = recipeData.title.toLowerCase(); const textClean = text.toLowerCase(); if (titleClean.includes(textClean) || textClean.includes(titleClean)) return true; if (/^(ingredients|instructions|directions|method|results|notes|conclusion)\b/i.test(textClean)) return true; return false; }; let selectedContainer = null; if ($('blockquote').length > 0) { selectedContainer = $('blockquote'); } else if ($('.entry-content').length > 0) { selectedContainer = $('.entry-content'); } else if ($('article').length > 0) { selectedContainer = $('article'); } if (selectedContainer) { selectedContainer.each((i, container) => { const paragraphs = $(container).find('p'); if (paragraphs.length > 2) { paragraphs.each((j, el) => { const html = $(el).html() || ''; if (html.includes('
')) { const lines = html.split(//i) .map(l => cheerio.load(l).text().trim().replace(/\s+/g, ' ')) .filter(Boolean); for (const line of lines) { if (isTitleOrHeader(line) || line.length < 3) continue; if (isStepText(line)) { recipeData.instructions.push(line); } else { recipeData.ingredients.push(line); } } } else { const txt = $(el).text().trim().replace(/\s+/g, ' '); if (!txt || isTitleOrHeader(txt) || txt.length < 3) return; if (isStepText(txt)) { recipeData.instructions.push(txt); } else { recipeData.ingredients.push(txt); } } }); if (recipeData.ingredients.length > 1) return false; } }); } } // Clean up lists recipeData.ingredients = recipeData.ingredients.map(i => i.trim()).filter(Boolean); recipeData.instructions = recipeData.instructions.map(i => i.trim()).filter(Boolean); recipeData.images = extractedImages.slice(0, 15); return recipeData; } // Helper: Detect if HTML response is a bot challenge page rather than real content function isBotChallengePage(html) { if (!html || typeof html !== 'string') return true; const lowerHtml = html.toLowerCase(); if (lowerHtml.includes('cf-browser-verification') || lowerHtml.includes('cf_chl_opt')) return true; if (lowerHtml.includes('checking your browser') && lowerHtml.includes('cloudflare')) return true; if (lowerHtml.includes('akamai') && lowerHtml.includes('bot manager')) return true; if (lowerHtml.includes('px-captcha') || lowerHtml.includes('perimeterx')) return true; if (lowerHtml.includes('access to this page has been denied')) return true; if (lowerHtml.includes('please verify you are a human') && !lowerHtml.includes('recipeingredient')) return true; if (lowerHtml.includes('recaptcha') && !lowerHtml.includes('recipeingredient')) return true; if (html.length < 500 && !lowerHtml.includes('recipe')) return true; return false; } // Helper: Fetch full HTML via Jina Reader HTML mode to bypass edge bot protection (PerimeterX, Cloudflare, etc.) async function fetchWithReaderProxy(url) { console.log('[ReaderProxy] Fetching rendered HTML for:', url); const response = await axios.get(`https://r.jina.ai/${url}`, { headers: { 'X-Return-Format': 'html', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, timeout: 20000 }); return response.data; } // Helper: Fetch HTML using Puppeteer with stealth (headless browser fallback) async function fetchWithPuppeteer(url) { console.log('[Puppeteer] Launching headless browser for:', url); let browser = null; try { browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--window-size=1920,1080' ] }); const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080 }); await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' }); await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); await new Promise(resolve => setTimeout(resolve, 2000)); await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight / 2); }); await new Promise(resolve => setTimeout(resolve, 1000)); const html = await page.content(); console.log('[Puppeteer] Successfully retrieved', html.length, 'bytes of HTML'); return html; } finally { if (browser) { await browser.close(); console.log('[Puppeteer] Browser closed'); } } } // API: Load System Settings & User Preferences app.get('/api/settings', requireAuth, (req, res) => { try { const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id); const defaultConsolidateLists = userRow ? userRow.defaultConsolidateLists !== 0 : true; res.json({ geminiApiKey: settings ? settings.geminiApiKey : '', defaultConsolidateLists }); } catch (e) { console.error('Failed to load settings:', e); res.status(500).json({ error: 'Failed to read settings' }); } }); // API: Save System Settings & User Preferences app.post('/api/settings', requireAuth, (req, res) => { const { geminiApiKey, defaultConsolidateLists } = req.body; try { if (geminiApiKey !== undefined) { db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || ''); } if (typeof defaultConsolidateLists === 'boolean') { db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id); req.user.defaultConsolidateLists = defaultConsolidateLists; } const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); const userRow = db.prepare('SELECT defaultConsolidateLists FROM users WHERE id = ?').get(req.user.id); res.json({ geminiApiKey: settings ? settings.geminiApiKey : '', defaultConsolidateLists: userRow ? userRow.defaultConsolidateLists !== 0 : true }); } catch (e) { console.error('Failed to save settings:', e); res.status(500).json({ error: 'Failed to save settings' }); } }); // API: Update User Preferences directly app.put('/api/user/preferences', requireAuth, (req, res) => { const { defaultConsolidateLists } = req.body; if (typeof defaultConsolidateLists !== 'boolean') { return res.status(400).json({ error: 'defaultConsolidateLists must be a boolean.' }); } try { db.prepare('UPDATE users SET defaultConsolidateLists = ? WHERE id = ?').run(defaultConsolidateLists ? 1 : 0, req.user.id); req.user.defaultConsolidateLists = defaultConsolidateLists; res.json({ success: true, defaultConsolidateLists }); } catch (e) { console.error('Failed to update user preferences:', e); res.status(500).json({ error: 'Failed to update preferences.' }); } }); // ------------------------------------------------------------- // SHOPPING LIST PARSER, CLASSIFIER & API ENDPOINTS // ------------------------------------------------------------- const UNIT_ALIASES = { tablespoon: 'tbsp', tablespoons: 'tbsp', tbsp: 'tbsp', tbs: 'tbsp', tb: 'tbsp', teaspoon: 'tsp', teaspoons: 'tsp', tsp: 'tsp', cup: 'cup', cups: 'cup', c: 'cup', ounce: 'oz', ounces: 'oz', oz: 'oz', pound: 'lb', pounds: 'lb', lb: 'lb', lbs: 'lb', gram: 'g', grams: 'g', g: 'g', kilogram: 'kg', kilograms: 'kg', kg: 'kg', milliliter: 'ml', milliliters: 'ml', ml: 'ml', liter: 'l', liters: 'l', l: 'l', clove: 'clove', cloves: 'clove', can: 'can', cans: 'can', jar: 'jar', jars: 'jar', bunch: 'bunch', bunches: 'bunch', pinch: 'pinch', pinches: 'pinch', dash: 'dash', dashes: 'dash', slice: 'slice', slices: 'slice', piece: 'piece', pieces: 'piece', head: 'head', heads: 'head', stalk: 'stalk', stalks: 'stalk', sprig: 'sprig', sprigs: 'sprig', package: 'pkg', packages: 'pkg', pkg: 'pkg', pkgs: 'pkg', stick: 'stick', sticks: 'stick' }; const AISLE_KEYWORDS = [ { category: 'Produce', keywords: [ 'onion', 'shallot', 'scallion', 'garlic', 'ginger', 'potato', 'sweet potato', 'carrot', 'celery', 'bell pepper', 'jalapeno', 'chili pepper', 'poblano', 'habanero', 'serrano', 'tomato', 'cherry tomato', 'lettuce', 'spinach', 'kale', 'arugula', 'cabbage', 'broccoli', 'cauliflower', 'zucchini', 'squash', 'cucumber', 'mushroom', 'avocado', 'corn', 'asparagus', 'green bean', 'pea', 'eggplant', 'radish', 'beet', 'turnip', 'leek', 'fennel', 'artichoke', 'cilantro', 'parsley', 'basil', 'mint', 'rosemary', 'thyme', 'dill', 'chive', 'sage', 'apple', 'banana', 'lemon', 'lime', 'orange', 'grapefruit', 'berry', 'strawberry', 'blueberry', 'raspberry', 'blackberry', 'grape', 'peach', 'pear', 'mango', 'pineapple', 'watermelon', 'melon', 'pomegranate', 'fig', 'date' ] }, { category: 'Meat & Seafood', keywords: [ 'beef', 'ground beef', 'steak', 'chuck', 'sirloin', 'brisket', 'roast', 'pork', 'bacon', 'sausage', 'ham', 'prosciutto', 'pancetta', 'chorizo', 'pork chop', 'tenderloin', 'chicken', 'turkey', 'duck', 'lamb', 'veal', 'salmon', 'tuna', 'cod', 'halibut', 'tilapia', 'trout', 'mahimahi', 'sea bass', 'shrimp', 'prawn', 'scallop', 'crab', 'lobster', 'clam', 'mussel', 'oyster', 'anchovy', 'fish' ] }, { category: 'Dairy & Eggs', keywords: [ 'milk', 'cream', 'heavy cream', 'half-and-half', 'half and half', 'butter', 'margarine', 'egg', 'cheese', 'cheddar', 'mozzarella', 'parmesan', 'pecorino', 'feta', 'ricotta', 'gouda', 'gruyere', 'swiss', 'provolone', 'brie', 'goat cheese', 'cream cheese', 'sour cream', 'yogurt', 'greek yogurt', 'buttermilk', 'whipped cream', 'ghee', 'mascarpone' ] }, { category: 'Canned & Jarred', keywords: [ 'canned', 'crushed tomato', 'diced tomato', 'tomato paste', 'tomato sauce', 'marinara', 'salsa', 'kidney bean', 'black bean', 'pinto bean', 'cannellini', 'chickpea', 'garbanzo', 'lentil', 'broth', 'stock', 'chicken broth', 'beef broth', 'vegetable broth', 'coconut milk', 'olives', 'capers', 'pickle', 'jam', 'jelly', 'peanut butter', 'almond butter', 'tahini', 'artichoke heart', 'roasted red pepper', 'chipotle in adobo', 'beans' ] }, { category: 'Bakery & Grains', keywords: [ 'bread', 'loaf', 'baguette', 'ciabatta', 'sourdough', 'roll', 'bun', 'tortilla', 'pita', 'naan', 'bagel', 'croissant', 'english muffin', 'pasta', 'spaghetti', 'penne', 'fettuccine', 'linguine', 'macaroni', 'lasagna', 'orzo', 'noodle', 'ramen', 'udon', 'rice', 'jasmine rice', 'basmati', 'arborio', 'brown rice', 'quinoa', 'couscous', 'barley', 'oats', 'oatmeal', 'breadcrumb', 'panko', 'cracker', 'taco shell', 'tortilla chip' ] }, { category: 'Pantry & Spices', keywords: [ 'olive oil', 'vegetable oil', 'canola oil', 'sesame oil', 'coconut oil', 'avocado oil', 'oil', 'vinegar', 'apple cider vinegar', 'balsamic', 'red wine vinegar', 'white wine vinegar', 'rice vinegar', 'soy sauce', 'tamari', 'fish sauce', 'worcestershire', 'hot sauce', 'sriracha', 'mustard', 'dijon', 'ketchup', 'mayonnaise', 'mayo', 'honey', 'maple syrup', 'molasses', 'agave', 'sugar', 'brown sugar', 'powdered sugar', 'confectioners', 'flour', 'all-purpose flour', 'cornstarch', 'baking powder', 'baking soda', 'yeast', 'vanilla', 'cocoa', 'chocolate', 'chip', 'nut', 'almond', 'walnut', 'pecan', 'cashew', 'peanut', 'pine nut', 'pistachio', 'seed', 'sesame', 'chia', 'flax', 'sunflower', 'salt', 'kosher salt', 'sea salt', 'black pepper', 'pepper', 'chili powder', 'cumin', 'paprika', 'smoked paprika', 'cayenne', 'oregano', 'garlic powder', 'onion powder', 'cinnamon', 'nutmeg', 'cloves', 'allspice', 'cardamom', 'turmeric', 'curry', 'coriander', 'bay leaf', 'bay leaves', 'red pepper flakes', 'italian seasoning', 'thyme', 'rosemary', 'spice', 'seasoning', 'bouillon' ] }, { category: 'Frozen', keywords: [ 'frozen', 'ice cream', 'puff pastry', 'pie crust', 'frozen pea', 'frozen corn', 'frozen berry', 'edamame', 'tater tot', 'fries' ] } ]; function classifyGroceryCategory(itemName, rawText) { const haystack = `${itemName} ${rawText}`.toLowerCase(); // Check Canned & Jarred first if "can" or "canned" or "jar" is explicitly present if (/\b(can|cans|canned|jar|jars|jarred)\b/.test(haystack)) { for (const kw of AISLE_KEYWORDS.find(a => a.category === 'Canned & Jarred').keywords) { if (haystack.includes(kw)) return 'Canned & Jarred'; } } for (const group of AISLE_KEYWORDS) { for (const kw of group.keywords) { const regex = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?\\b`, 'i'); if (regex.test(haystack)) { return group.category; } } } return 'Other'; } function parseIngredientLine(rawInput) { const rawText = (rawInput || '').trim().replace(/\s+/g, ' '); if (!rawText) { return { rawText: '', itemName: '', quantity: null, unit: '', category: 'Other' }; } // Normalize unicode vulgar fractions for parsing const unicodeMap = { '½': ' 1/2', '⅓': ' 1/3', '⅔': ' 2/3', '¼': ' 1/4', '¾': ' 3/4', '⅕': ' 1/5', '⅖': ' 2/5', '⅗': ' 3/5', '⅘': ' 4/5', '⅙': ' 1/6', '⅚': ' 5/6', '⅛': ' 1/8', '⅜': ' 3/8', '⅝': ' 5/8', '⅞': ' 7/8' }; let working = rawText.replace(/[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞]/g, m => unicodeMap[m] || m).trim().replace(/\s+/g, ' '); // Remove parenthetical notes like "(about 1 1/2 cups)" or "(15-ounce)" let withoutParens = working.replace(/\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim(); let quantity = null; let rest = withoutParens; // Match mixed fraction "1 1/2", simple fraction "3/4", range "1-2" or "1 to 2", or decimal/integer const mixedMatch = rest.match(/^(\d+)\s+(\d+)\/(\d+)\b\s*(.*)/); const fracMatch = !mixedMatch && rest.match(/^(\d+)\/(\d+)\b\s*(.*)/); const rangeMatch = !mixedMatch && !fracMatch && rest.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)\b\s*(.*)/i); const numMatch = !mixedMatch && !fracMatch && !rangeMatch && rest.match(/^(\d+(?:\.\d+)?)\b\s*(.*)/); if (mixedMatch) { quantity = parseInt(mixedMatch[1], 10) + (parseInt(mixedMatch[2], 10) / parseInt(mixedMatch[3], 10)); rest = mixedMatch[4]; } else if (fracMatch) { quantity = parseInt(fracMatch[1], 10) / parseInt(fracMatch[2], 10); rest = fracMatch[3]; } else if (rangeMatch) { quantity = parseFloat(rangeMatch[2]); rest = rangeMatch[3]; } else if (numMatch) { quantity = parseFloat(numMatch[1]); rest = numMatch[2]; } if (quantity !== null && (!isFinite(quantity) || isNaN(quantity))) { quantity = null; } // Extract unit if first token matches UNIT_ALIASES let unit = ''; const unitTokenMatch = rest.match(/^([a-zA-Z]+)\.?\b\s*(.*)/); if (unitTokenMatch) { const candidate = unitTokenMatch[1].toLowerCase(); if (UNIT_ALIASES[candidate]) { unit = UNIT_ALIASES[candidate]; rest = unitTokenMatch[2].replace(/^of\b\s*/i, ''); } } // Clean itemName: strip trailing prep instructions after comma (e.g. ", diced", ", finely chopped") let itemName = rest.split(',')[0].trim(); // Remove leading descriptors like "medium ", "large ", "small ", "fresh ", "boneless skinless " itemName = itemName .replace(/^(?:of\s+|medium\s+|large\s+|small\s+|extra-large\s+|fresh\s+|dried\s+|finely\s+|chopped\s+|diced\s+|minced\s+|sliced\s+|grated\s+|shredded\s+)+/i, '') .trim() .toLowerCase(); // Basic singularization for better consolidation matching if (itemName.length > 4) { if (itemName.endsWith('tomatoes')) itemName = itemName.slice(0, -2); else if (itemName.endsWith('potatoes')) itemName = itemName.slice(0, -2); else if (itemName.endsWith('berries')) itemName = itemName.slice(0, -3) + 'y'; else if (itemName.endsWith('leaves')) itemName = itemName.slice(0, -3) + 'f'; else if (itemName.endsWith('s') && !itemName.endsWith('ss') && !itemName.endsWith('us') && !itemName.endsWith('beans') && !itemName.endsWith('oats') && !itemName.endsWith('flakes') && !itemName.endsWith('noodles') && !itemName.endsWith('chips')) { itemName = itemName.slice(0, -1); } } if (!itemName) { itemName = rawText.toLowerCase(); } const category = classifyGroceryCategory(itemName, rawText); return { rawText, itemName, quantity: quantity !== null ? Math.round(quantity * 100) / 100 : null, unit, category }; } function formatShoppingList(listRow) { if (!listRow) return null; const items = db.prepare('SELECT * FROM shopping_list_items WHERE listId = ? ORDER BY createdAt ASC').all(listRow.id).map(item => ({ ...item, checked: item.checked === 1 })); let visitorCount = 0; if (listRow.shareToken) { try { const stats = db.prepare('SELECT COUNT(*) as count FROM shopping_list_shares_visits WHERE shareToken = ?').get(listRow.shareToken); visitorCount = stats ? stats.count : 0; } catch (e) { console.error('Failed to query shopping list visitor count:', e); } } return { ...listRow, items, visitorCount }; } // API: Get all shopping lists for current user (creates a default list if none exist) app.get('/api/shopping-lists', requireAuth, (req, res) => { try { let rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id); if (rows.length === 0) { const defaultId = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); const now = new Date().toISOString(); db.prepare(` INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt) VALUES (?, ?, ?, ?, NULL, ?, NULL) `).run(defaultId, req.user.id, req.user.username, 'Weekly Groceries', now); rows = db.prepare('SELECT * FROM shopping_lists WHERE userId = ? ORDER BY createdAt ASC').all(req.user.id); } res.json(rows.map(formatShoppingList)); } catch (error) { console.error('Error fetching shopping lists:', error); res.status(500).json({ error: 'Failed to retrieve shopping lists.' }); } }); // API: Create a new shopping list app.post('/api/shopping-lists', requireAuth, (req, res) => { const name = (req.body.name || '').trim(); if (!name) { return res.status(400).json({ error: 'Shopping list name is required.' }); } try { const id = 'sl_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); const now = new Date().toISOString(); db.prepare(` INSERT INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt) VALUES (?, ?, ?, ?, NULL, ?, NULL) `).run(id, req.user.id, req.user.username, name, now); const created = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(id)); res.status(201).json(created); } catch (error) { console.error('Error creating shopping list:', error); res.status(500).json({ error: 'Failed to create shopping list.' }); } }); // API: Rename a shopping list app.put('/api/shopping-lists/:id', requireAuth, (req, res) => { const name = (req.body.name || '').trim(); if (!name) { return res.status(400).json({ error: 'Shopping list name is required.' }); } try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } db.prepare('UPDATE shopping_lists SET name = ?, updatedAt = ? WHERE id = ?').run(name, new Date().toISOString(), req.params.id); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id)); res.json(updated); } catch (error) { console.error('Error renaming shopping list:', error); res.status(500).json({ error: 'Failed to rename shopping list.' }); } }); // API: Delete a shopping list app.delete('/api/shopping-lists/:id', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const delTx = db.transaction(() => { if (existing.shareToken) { db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(existing.shareToken); } db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id); }); delTx(); res.json({ success: true }); } catch (error) { console.error('Error deleting shopping list:', error); res.status(500).json({ error: 'Failed to delete shopping list.' }); } }); // API: Add one or more items to a shopping list app.post('/api/shopping-lists/:id/items', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const rawItems = Array.isArray(req.body.items) ? req.body.items : (req.body.text ? [req.body.text] : []); const recipeId = req.body.recipeId || null; const recipeTitle = req.body.recipeTitle || null; if (rawItems.length === 0) { return res.status(400).json({ error: 'No items provided.' }); } const insertStmt = db.prepare(` INSERT INTO shopping_list_items ( id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?) `); const now = new Date().toISOString(); const insertMany = db.transaction((lines) => { lines.forEach((line, idx) => { if (!line || typeof line !== 'string' || !line.trim()) return; const parsed = parseIngredientLine(line); const itemId = 'sli_' + Date.now().toString(36) + '_' + idx + '_' + Math.random().toString(36).substr(2, 4); insertStmt.run( itemId, existing.id, parsed.rawText, parsed.itemName, parsed.quantity, parsed.unit, parsed.category, recipeId, recipeTitle, now ); }); db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(now, existing.id); }); insertMany(rawItems); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.status(201).json(updated); } catch (error) { console.error('Error adding shopping list items:', error); res.status(500).json({ error: 'Failed to add items to shopping list.' }); } }); // API: Toggle checked state for one or multiple shopping list items app.put('/api/shopping-lists/:id/items/check', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : []; const checked = req.body.checked ? 1 : 0; if (itemIds.length === 0) { return res.status(400).json({ error: 'itemIds array is required.' }); } const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?'); const tx = db.transaction((ids) => { for (const itemId of ids) { updateStmt.run(checked, itemId, existing.id); } db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id); }); tx(itemIds); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.json(updated); } catch (error) { console.error('Error toggling shopping list items:', error); res.status(500).json({ error: 'Failed to update item state.' }); } }); // API: Delete one or multiple items from a shopping list app.post('/api/shopping-lists/:id/items/delete', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : []; const delStmt = db.prepare('DELETE FROM shopping_list_items WHERE id = ? AND listId = ?'); const tx = db.transaction((ids) => { for (const itemId of ids) { delStmt.run(itemId, existing.id); } db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id); }); tx(itemIds); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.json(updated); } catch (error) { console.error('Error deleting shopping list items:', error); res.status(500).json({ error: 'Failed to delete items.' }); } }); // API: Clear checked or all items from a shopping list app.post('/api/shopping-lists/:id/clear', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const onlyChecked = req.body.onlyChecked !== false; if (onlyChecked) { db.prepare('DELETE FROM shopping_list_items WHERE listId = ? AND checked = 1').run(existing.id); } else { db.prepare('DELETE FROM shopping_list_items WHERE listId = ?').run(existing.id); } db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.json(updated); } catch (error) { console.error('Error clearing shopping list:', error); res.status(500).json({ error: 'Failed to clear shopping list.' }); } }); // API: Generate or retrieve public share link token for a shopping list app.post('/api/shopping-lists/:id/share', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } let shareToken = existing.shareToken; if (!shareToken) { shareToken = crypto.randomBytes(16).toString('hex'); db.prepare('UPDATE shopping_lists SET shareToken = ?, updatedAt = ? WHERE id = ?').run(shareToken, new Date().toISOString(), existing.id); } const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.json(updated); } catch (error) { console.error('Error sharing shopping list:', error); res.status(500).json({ error: 'Failed to generate shopping list share link.' }); } }); // API: Revoke public share link for a shopping list app.post('/api/shopping-lists/:id/revoke-share', requireAuth, (req, res) => { try { const existing = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(req.params.id); if (!existing) return res.status(404).json({ error: 'Shopping list not found.' }); if (existing.userId !== req.user.id && !req.user.isAdmin) { return res.status(403).json({ error: 'Permission denied.' }); } const oldToken = existing.shareToken; const revokeTx = db.transaction(() => { db.prepare('UPDATE shopping_lists SET shareToken = NULL, updatedAt = ? WHERE id = ?').run(new Date().toISOString(), existing.id); if (oldToken) { db.prepare('DELETE FROM shopping_list_shares_visits WHERE shareToken = ?').run(oldToken); } }); revokeTx(); const updated = formatShoppingList(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(existing.id)); res.json(updated); } catch (error) { console.error('Error revoking shopping list share link:', error); res.status(500).json({ error: 'Failed to revoke shopping list share link.' }); } }); // Public API: View shared shopping list by shareToken app.get('/api/share/shopping-lists/:shareToken', publicShareLimiter, (req, res) => { const shareToken = (req.params.shareToken || '').trim(); if (!shareToken) { return res.status(400).json({ error: 'Share token is required.' }); } try { const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken); if (!listRow) { return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' }); } try { const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; db.prepare(` INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?) `).run(shareToken, ip, new Date().toISOString()); } catch (visitErr) { console.error('Failed to log shared shopping list visitor IP:', visitErr); } const formatted = formatShoppingList(listRow); res.json({ name: formatted.name, createdBy: formatted.createdBy, createdAt: formatted.createdAt, updatedAt: formatted.updatedAt, items: formatted.items }); } catch (error) { console.error('Error serving shared shopping list:', error); res.status(500).json({ error: 'Internal server error while retrieving shared shopping list.' }); } }); // Public API: Check/uncheck items on a shared shopping list app.put('/api/share/shopping-lists/:shareToken/items/check', publicShareLimiter, (req, res) => { const shareToken = (req.params.shareToken || '').trim(); if (!shareToken) { return res.status(400).json({ error: 'Share token is required.' }); } try { const listRow = db.prepare('SELECT * FROM shopping_lists WHERE shareToken = ?').get(shareToken); if (!listRow) { return res.status(404).json({ error: 'Shopping list not found or sharing has been disabled.' }); } const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : []; const checked = req.body.checked ? 1 : 0; if (itemIds.length === 0) { return res.status(400).json({ error: 'itemIds array is required.' }); } const updateStmt = db.prepare('UPDATE shopping_list_items SET checked = ? WHERE id = ? AND listId = ?'); const tx = db.transaction((ids) => { for (const itemId of ids) { updateStmt.run(checked, itemId, listRow.id); } db.prepare('UPDATE shopping_lists SET updatedAt = ? WHERE id = ?').run(new Date().toISOString(), listRow.id); }); tx(itemIds); const formatted = formatShoppingList(listRow); res.json({ name: formatted.name, createdBy: formatted.createdBy, createdAt: formatted.createdAt, updatedAt: formatted.updatedAt, items: formatted.items }); } catch (error) { console.error('Error updating shared shopping list items:', error); res.status(500).json({ error: 'Failed to update shared shopping list.' }); } }); // API: Sanity-checked, unauthenticated public recipe sharing with rate limiting app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => { const shareToken = req.params.shareToken; if (!shareToken || shareToken.trim() === '') { return res.status(400).json({ error: 'Share token is required.' }); } try { const recipe = db.prepare('SELECT * FROM recipes WHERE shareToken = ? AND isPublic = 1').get(shareToken); if (!recipe) { return res.status(404).json({ error: 'Recipe not found or is set to private.' }); } // Log guest visitor IP dynamically inside the background try { const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; db.prepare(` INSERT OR IGNORE INTO recipe_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?) `).run(shareToken, ip, new Date().toISOString()); } catch (visitErr) { console.error('Failed to log shared visitor IP:', visitErr); } // Strip sensitive user identifiers and internal database keys const sanitizedRecipe = { title: recipe.title, description: recipe.description, servings: recipe.servings, prepTime: recipe.prepTime, cookTime: recipe.cookTime, totalTime: recipe.totalTime, image: recipe.image, images: JSON.parse(recipe.images || '[]'), ingredients: JSON.parse(recipe.ingredients || '[]'), instructions: JSON.parse(recipe.instructions || '[]'), sourceUrl: recipe.sourceUrl, createdBy: recipe.createdBy, createdAt: recipe.createdAt }; res.json(sanitizedRecipe); } catch (error) { console.error('Error serving shared recipe:', error); res.status(500).json({ error: 'Internal server error while retrieving shared recipe.' }); } }); // API: Revoke public recipe sharing link (requires authenticated ownership or permission) app.post('/api/recipes/:id/revoke-share', requireAuth, (req, res) => { try { const recipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); if (!recipe) { return res.status(404).json({ error: 'Recipe not found.' }); } // Verify permissions: owner, recipe manager, or admin if (recipe.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { return res.status(403).json({ error: 'You do not have permission to revoke this recipe share link.' }); } const oldShareToken = recipe.shareToken; // Relational transaction to clear sharing state and purge visits data const performRevocation = db.transaction(() => { db.prepare('UPDATE recipes SET isPublic = 0, shareToken = NULL WHERE id = ?').run(req.params.id); if (oldShareToken) { db.prepare('DELETE FROM recipe_shares_visits WHERE shareToken = ?').run(oldShareToken); } }); performRevocation(); res.json({ success: true }); } catch (error) { console.error('Error revoking recipe share link:', error); res.status(500).json({ error: 'Failed to revoke shared link.' }); } }); // API: Scrape Recipe from Link app.post('/api/scrape', requireAuth, async (req, res) => { const { url } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } try { let html = null; let scrapingMethod = 'axios'; // Strategy 1: Fast axios fetch try { const response = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', 'Upgrade-Insecure-Requests': '1' }, timeout: 10000 }); html = response.data; if (isBotChallengePage(html)) { console.log('[Scraper] Axios response appears to be a bot challenge page, falling back...'); html = null; } } catch (axiosError) { const status = axiosError.response ? axiosError.response.status : 0; if (status === 403 || status === 503 || status === 429) { console.log(`[Scraper] Axios blocked with HTTP ${status}, falling back...`); } else { throw axiosError; } } // Strategy 2: Reader Proxy HTML fetch (fast bypass for PerimeterX / Cloudflare edge blocks) if (!html) { try { const readerHtml = await fetchWithReaderProxy(url); if (!isBotChallengePage(readerHtml)) { html = readerHtml; scrapingMethod = 'reader-proxy'; } else { console.log('[ReaderProxy] Returned challenge page, falling back to Puppeteer...'); } } catch (readerError) { console.warn('[ReaderProxy] Fallback failed, trying Puppeteer:', readerError.message); } } // Strategy 3: Puppeteer with stealth (headless browser fallback) if (!html) { try { const puppeteerHtml = await fetchWithPuppeteer(url); if (!isBotChallengePage(puppeteerHtml)) { html = puppeteerHtml; scrapingMethod = 'puppeteer-stealth'; } else { throw new Error('Blocked by bot challenge page in headless browser.'); } } catch (puppeteerError) { console.error('[Puppeteer] Fallback also failed:', puppeteerError.message); return res.status(500).json({ error: 'This website could not be accessed. All scraping methods were blocked.', details: puppeteerError.message, isBlocked: true }); } } const recipeData = parseRecipeFromHtml(html, url); recipeData._scrapingMethod = scrapingMethod; res.json(recipeData); } catch (error) { console.error('Error occurred while scraping URL:', error.message); const status = error.response ? error.response.status : 500; const message = error.response && error.response.status === 403 ? 'This website is protected and blocks automated access. The Puppeteer fallback was also unavailable.' : error.message; res.status(status).json({ error: message, isBlocked: status === 403 || status === 503 }); } }); // ------------------------------------------------------------- // ADMIN CLOUD & MANUAL BACKUP ENGINE (ZERO LOCAL STORAGE) // ------------------------------------------------------------- const DEFAULT_GDRIVE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '202264815644.apps.googleusercontent.com'; const DEFAULT_GDRIVE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || 'X4Z3ca8xfWDb1Voo-F9a7ZxJ'; const GDRIVE_LOOPBACK_PORT = 53682; function getBackupConfig() { const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get(); if (!row) return null; const isConnected = Boolean(row.gdriveRefreshToken && row.gdriveRefreshToken.trim()); return { ...row, schedule: row.scheduleFrequency || 'off', includeRemoteImages: row.embedRemoteImages === 1, embedRemoteImages: row.embedRemoteImages === 1, s3Enabled: row.s3Enabled === 1, gdriveEnabled: row.gdriveEnabled === 1, gdriveConnected: isConnected, gdriveAccountEmail: row.gdriveAccountEmail || '', gdriveFolderName: row.gdriveFolderName || 'ForkFolio Backups', lastRunAt: row.lastBackupAt || null, lastRunStatus: row.lastBackupStatus === 'failed' ? 'error' : (row.lastBackupStatus || null) }; } // Create an atomic SQLite .db snapshot in memory Buffer via temporary OS file, immediately unlinked function createSqliteSnapshotBuffer() { const tempPath = path.join( os.tmpdir(), `forkfolio_tmp_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db` ); try { const escapedPath = tempPath.replace(/'/g, "''"); db.exec(`VACUUM INTO '${escapedPath}'`); const buf = fs.readFileSync(tempPath); return buf; } finally { try { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } catch (cleanupErr) { console.warn('[Backup] Temp file cleanup warning:', cleanupErr.message); } } } // Create a full JSON archive Buffer (users, recipes, embedded images, shopping lists, settings, visit stats) async function createJsonArchiveData(embedRemoteImages = true) { const users = db.prepare('SELECT * FROM users').all(); const recipes = db.prepare('SELECT * FROM recipes').all(); const shoppingLists = db.prepare('SELECT * FROM shopping_lists').all(); const shoppingListItems = db.prepare('SELECT * FROM shopping_list_items').all(); const settings = db.prepare('SELECT * FROM settings').all(); const recipeSharesVisits = db.prepare('SELECT * FROM recipe_shares_visits').all(); const shoppingListSharesVisits = db.prepare('SELECT * FROM shopping_list_shares_visits').all(); let imageCount = 0; const enrichedRecipes = []; for (const r of recipes) { const copy = { ...r }; if (copy.image && copy.image.startsWith('data:image/')) { imageCount++; } else if (copy.image && /^https?:\/\//i.test(copy.image)) { imageCount++; if (embedRemoteImages) { try { const resp = await axios.get(copy.image, { responseType: 'arraybuffer', timeout: 5000, headers: { 'User-Agent': 'Mozilla/5.0' } }); const contentType = resp.headers['content-type'] || 'image/jpeg'; const base64 = Buffer.from(resp.data).toString('base64'); copy.embeddedCoverImage = `data:${contentType};base64,${base64}`; } catch (imgErr) { // Keep original URL if remote image cannot be fetched } } } enrichedRecipes.push(copy); } const archiveObj = { version: '1.0', exportedAt: new Date().toISOString(), counts: { users: users.length, recipes: enrichedRecipes.length, images: imageCount, shoppingLists: shoppingLists.length, shoppingListItems: shoppingListItems.length }, data: { users, recipes: enrichedRecipes, shoppingLists, shoppingListItems, settings, recipeSharesVisits, shoppingListSharesVisits } }; const buffer = Buffer.from(JSON.stringify(archiveObj, null, 2), 'utf8'); return { buffer, userCount: users.length, recipeCount: enrichedRecipes.length, imageCount, listCount: shoppingLists.length }; } // ------------------------------------------------------------- // AWS SIGNATURE V4 HELPER FOR WASABI / S3-COMPATIBLE STORAGE // ------------------------------------------------------------- function hmacSha256(key, data) { return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); } function sha256Hex(data) { return crypto.createHash('sha256').update(data).digest('hex'); } function encodeS3URI(uriPath, encodeSlash = false) { return uriPath .split('/') .map(segment => encodeURIComponent(segment).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()) ) .join(encodeSlash ? '%2F' : '/'); } function buildS3SignedRequest({ method, endpoint, region, bucket, objectKey = '', queryParams = {}, headers = {}, body = Buffer.alloc(0), accessKeyId, secretAccessKey }) { const baseEndpoint = (endpoint || 'https://s3.us-east-1.wasabisys.com').trim().replace(/\/+$/, ''); const parsedEndpoint = new URL(baseEndpoint.startsWith('http') ? baseEndpoint : `https://${baseEndpoint}`); const cleanBucket = (bucket || '').trim(); const cleanKey = (objectKey || '').replace(/^\/+/, ''); const pathSegments = ['']; if (cleanBucket) pathSegments.push(cleanBucket); if (cleanKey) pathSegments.push(cleanKey); const rawPath = pathSegments.join('/') || '/'; const canonicalUri = encodeS3URI(rawPath, false); const sortedQueryKeys = Object.keys(queryParams).sort(); const canonicalQueryString = sortedQueryKeys .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(String(queryParams[k]))}`) .join('&'); const now = new Date(); const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); const dateStamp = amzDate.slice(0, 8); const payloadHash = sha256Hex(body); const signHeaders = { host: parsedEndpoint.host, 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate, ...headers }; const lowerHeaders = {}; for (const [k, v] of Object.entries(signHeaders)) { lowerHeaders[k.toLowerCase()] = String(v).trim(); } const signedHeaderKeys = Object.keys(lowerHeaders).sort(); const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${lowerHeaders[k]}\n`).join(''); const signedHeadersStr = signedHeaderKeys.join(';'); const canonicalRequest = [ method.toUpperCase(), canonicalUri, canonicalQueryString, canonicalHeaders, signedHeadersStr, payloadHash ].join('\n'); const s3Region = (region || 'us-east-1').trim(); const credentialScope = `${dateStamp}/${s3Region}/s3/aws4_request`; const stringToSign = [ 'AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest) ].join('\n'); const kDate = hmacSha256(`AWS4${secretAccessKey}`, dateStamp); const kRegion = hmacSha256(kDate, s3Region); const kService = hmacSha256(kRegion, 's3'); const kSigning = hmacSha256(kService, 'aws4_request'); const signature = crypto.createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex'); const authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeadersStr}, Signature=${signature}`; const fullUrl = `${parsedEndpoint.origin}${canonicalUri}${canonicalQueryString ? '?' + canonicalQueryString : ''}`; const requestHeaders = { ...signHeaders, Authorization: authorization }; delete requestHeaders.host; return { url: fullUrl, headers: requestHeaders }; } async function s3TestConnection(cfg) { if (!cfg.s3Bucket || !cfg.s3AccessKeyId || !cfg.s3SecretAccessKey) { throw new Error('Bucket name, Access Key ID, and Secret Access Key are required.'); } const signed = buildS3SignedRequest({ method: 'GET', endpoint: cfg.s3Endpoint, region: cfg.s3Region, bucket: cfg.s3Bucket, queryParams: { 'list-type': '2', 'max-keys': '1' }, accessKeyId: cfg.s3AccessKeyId, secretAccessKey: cfg.s3SecretAccessKey }); const res = await fetch(signed.url, { method: 'GET', headers: signed.headers }); if (!res.ok) { const errText = await res.text(); throw new Error(`S3 returned HTTP ${res.status}: ${errText.slice(0, 220)}`); } return true; } async function s3PutObject(cfg, objectKey, buffer, contentType) { const signed = buildS3SignedRequest({ method: 'PUT', endpoint: cfg.s3Endpoint, region: cfg.s3Region, bucket: cfg.s3Bucket, objectKey, headers: { 'Content-Type': contentType, 'Content-Length': String(buffer.length) }, body: buffer, accessKeyId: cfg.s3AccessKeyId, secretAccessKey: cfg.s3SecretAccessKey }); const res = await fetch(signed.url, { method: 'PUT', headers: signed.headers, body: buffer }); if (!res.ok) { const errText = await res.text(); throw new Error(`S3 PUT failed (${res.status}): ${errText.slice(0, 220)}`); } return objectKey; } async function s3GetObject(cfg, objectKey) { const signed = buildS3SignedRequest({ method: 'GET', endpoint: cfg.s3Endpoint, region: cfg.s3Region, bucket: cfg.s3Bucket, objectKey, accessKeyId: cfg.s3AccessKeyId, secretAccessKey: cfg.s3SecretAccessKey }); const res = await fetch(signed.url, { method: 'GET', headers: signed.headers }); if (!res.ok) { const errText = await res.text(); throw new Error(`S3 GET failed (${res.status}): ${errText.slice(0, 200)}`); } const arrBuf = await res.arrayBuffer(); return Buffer.from(arrBuf); } async function s3DeleteObject(cfg, objectKey) { const signed = buildS3SignedRequest({ method: 'DELETE', endpoint: cfg.s3Endpoint, region: cfg.s3Region, bucket: cfg.s3Bucket, objectKey, accessKeyId: cfg.s3AccessKeyId, secretAccessKey: cfg.s3SecretAccessKey }); const res = await fetch(signed.url, { method: 'DELETE', headers: signed.headers }); if (!res.ok && res.status !== 204 && res.status !== 404) { const errText = await res.text(); throw new Error(`S3 DELETE failed (${res.status}): ${errText.slice(0, 200)}`); } } // ------------------------------------------------------------- // GOOGLE DRIVE REST API V3 CONNECTION CLIENT // ------------------------------------------------------------- async function getGoogleDriveAccessToken(cfg) { const clientId = (cfg.gdriveClientId && cfg.gdriveClientId.trim()) || DEFAULT_GDRIVE_CLIENT_ID; const clientSecret = (cfg.gdriveClientSecret && cfg.gdriveClientSecret.trim()) || DEFAULT_GDRIVE_CLIENT_SECRET; const refreshToken = (cfg.gdriveRefreshToken || '').trim(); if (!refreshToken) { throw new Error('Google Drive is not connected yet. Click "Authorize with Google" to connect your account.'); } const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }); const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.access_token) { throw new Error(data.error_description || data.error || `Google OAuth token exchange failed (${res.status}).`); } return data.access_token; } async function getGoogleDriveUserEmail(accessToken) { try { const res = await fetch('https://www.googleapis.com/drive/v3/about?fields=user(emailAddress,displayName)', { headers: { Authorization: `Bearer ${accessToken}` } }); if (res.ok) { const data = await res.json(); return data.user?.emailAddress || data.user?.displayName || ''; } } catch (e) {} return ''; } async function ensureGoogleDriveFolder(accessToken, cfg) { if (cfg.gdriveFolderId && cfg.gdriveFolderId.trim()) { const folderId = cfg.gdriveFolderId.trim(); const checkRes = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,trashed`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (checkRes.ok) { const f = await checkRes.json(); if (!f.trashed) { if (f.name && f.name !== cfg.gdriveFolderName) { db.prepare('UPDATE backup_config SET gdriveFolderName = ? WHERE id = 1').run(f.name); } return f.id; } } } // Search for existing "ForkFolio Backups" folder or create one const q = encodeURIComponent("mimeType='application/vnd.google-apps.folder' and name='ForkFolio Backups' and trashed=false"); const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name)`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (searchRes.ok) { const searchData = await searchRes.json(); if (Array.isArray(searchData.files) && searchData.files.length > 0) { const folderId = searchData.files[0].id; const folderName = searchData.files[0].name || 'ForkFolio Backups'; db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ? WHERE id = 1').run(folderId, folderName); return folderId; } } // Create folder const createRes = await fetch('https://www.googleapis.com/drive/v3/files?fields=id,name', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'ForkFolio Backups', mimeType: 'application/vnd.google-apps.folder' }) }); const created = await createRes.json().catch(() => ({})); if (!createRes.ok || !created.id) { throw new Error('Failed to verify or create Google Drive backup folder.'); } db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ? WHERE id = 1').run(created.id, created.name || 'ForkFolio Backups'); return created.id; } async function gdriveListFolders(accessToken) { const q = encodeURIComponent("mimeType='application/vnd.google-apps.folder' and trashed=false"); const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&pageSize=100&orderBy=name&fields=files(id,name,parents)`, { headers: { Authorization: `Bearer ${accessToken}` } }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error?.message || 'Failed to list Google Drive folders.'); } return Array.isArray(data.files) ? data.files : []; } async function gdriveCreateFolder(accessToken, folderName) { const cleanName = (folderName || '').trim() || 'ForkFolio Backups'; const res = await fetch('https://www.googleapis.com/drive/v3/files?fields=id,name', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: cleanName, mimeType: 'application/vnd.google-apps.folder' }) }); const created = await res.json().catch(() => ({})); if (!res.ok || !created.id) { throw new Error(created.error?.message || 'Failed to create folder in Google Drive.'); } return { id: created.id, name: created.name || cleanName }; } async function gdriveUploadFile(accessToken, folderId, filename, buffer, mimeType) { const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex'); const metadata = { name: filename, parents: folderId ? [folderId] : undefined }; const preamble = Buffer.from( `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n--${boundary}\r\nContent-Type: ${mimeType}\r\n\r\n`, 'utf8' ); const epilogue = Buffer.from(`\r\n--${boundary}--`, 'utf8'); const multipartBody = Buffer.concat([preamble, buffer, epilogue]); const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,size', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': `multipart/related; boundary=${boundary}`, 'Content-Length': String(multipartBody.length) }, body: multipartBody }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.id) { throw new Error(data.error?.message || `Google Drive upload failed (${res.status}).`); } return { id: data.id, name: data.name || filename, mimeType }; } async function gdriveDownloadFile(accessToken, fileId) { const res = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?alt=media`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok) { const errText = await res.text(); throw new Error(`Google Drive download failed (${res.status}): ${errText.slice(0, 200)}`); } const arrBuf = await res.arrayBuffer(); return Buffer.from(arrBuf); } async function gdriveDeleteFile(accessToken, fileId) { const res = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok && res.status !== 204 && res.status !== 404) { const errText = await res.text(); throw new Error(`Google Drive delete failed (${res.status}): ${errText.slice(0, 200)}`); } } // ------------------------------------------------------------- // CLOUD BACKUP EXECUTION & RETENTION PRUNING // ------------------------------------------------------------- let isBackupRunning = false; async function executeCloudBackup(triggerType = 'manual') { if (isBackupRunning) { throw new Error('A backup operation is already in progress.'); } isBackupRunning = true; const cfg = getBackupConfig(); const backupId = 'bkp_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 4); const now = new Date(); const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); const label = `forkfolio-backup-${stamp}`; const s3Objects = []; const gdriveFiles = []; try { if (!cfg.s3Enabled && !cfg.gdriveEnabled) { throw new Error('Neither Wasabi S3 nor Google Drive is enabled. Enable at least one cloud provider or use "Download to Computer".'); } const wantDb = cfg.backupFormat === 'both' || cfg.backupFormat === 'db'; const wantJson = cfg.backupFormat === 'both' || cfg.backupFormat === 'json'; const jsonArchive = await createJsonArchiveData(cfg.embedRemoteImages); const dbBuffer = wantDb ? createSqliteSnapshotBuffer() : null; const jsonBuffer = wantJson ? jsonArchive.buffer : null; const totalSize = (dbBuffer ? dbBuffer.length : 0) + (jsonBuffer ? jsonBuffer.length : 0); // 1. Upload to Wasabi S3 if enabled if (cfg.s3Enabled) { const prefix = (cfg.s3Prefix || 'forkfolio-backups/').trim().replace(/^\/+/, ''); const normalizedPrefix = prefix && !prefix.endsWith('/') ? `${prefix}/` : prefix; if (dbBuffer) { const dbKey = `${normalizedPrefix}${label}.db`; await s3PutObject(cfg, dbKey, dbBuffer, 'application/x-sqlite3'); s3Objects.push({ key: dbKey, name: `${label}.db`, format: 'db' }); } if (jsonBuffer) { const jsonKey = `${normalizedPrefix}${label}.json`; await s3PutObject(cfg, jsonKey, jsonBuffer, 'application/json'); s3Objects.push({ key: jsonKey, name: `${label}.json`, format: 'json' }); } } // 2. Upload to Google Drive if enabled if (cfg.gdriveEnabled) { const accessToken = await getGoogleDriveAccessToken(cfg); const folderId = await ensureGoogleDriveFolder(accessToken, cfg); if (dbBuffer) { const uploadedDb = await gdriveUploadFile(accessToken, folderId, `${label}.db`, dbBuffer, 'application/x-sqlite3'); gdriveFiles.push({ ...uploadedDb, format: 'db' }); } if (jsonBuffer) { const uploadedJson = await gdriveUploadFile(accessToken, folderId, `${label}.json`, jsonBuffer, 'application/json'); gdriveFiles.push({ ...uploadedJson, format: 'json' }); } } const createdAt = now.toISOString(); db.prepare(` INSERT INTO backup_history ( id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount, s3Objects, gdriveFiles, status, errorDetails, createdAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?) `).run( backupId, label, triggerType, totalSize, jsonArchive.userCount, jsonArchive.recipeCount, jsonArchive.imageCount, jsonArchive.listCount, JSON.stringify(s3Objects), JSON.stringify(gdriveFiles), createdAt ); db.prepare(` UPDATE backup_config SET lastBackupAt = ?, lastBackupStatus = 'success', lastBackupMessage = ? WHERE id = 1 `).run(createdAt, `Uploaded ${label} (${Math.round(totalSize / 1024)} KB)`); // Enforce retention limit on cloud backups await pruneOldCloudBackups(cfg); return db.prepare('SELECT * FROM backup_history WHERE id = ?').get(backupId); } catch (error) { const failedAt = new Date().toISOString(); db.prepare(` INSERT INTO backup_history ( id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount, s3Objects, gdriveFiles, status, errorDetails, createdAt ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, '[]', '[]', 'failed', ?, ?) `).run(backupId, label, triggerType, error.message, failedAt); db.prepare(` UPDATE backup_config SET lastBackupAt = ?, lastBackupStatus = 'failed', lastBackupMessage = ? WHERE id = 1 `).run(failedAt, error.message); throw error; } finally { isBackupRunning = false; } } async function pruneOldCloudBackups(cfg) { const retention = Number(cfg.retentionCount || 0); if (retention <= 0) return; const successfulRuns = db.prepare(` SELECT * FROM backup_history WHERE status = 'success' ORDER BY createdAt DESC `).all(); if (successfulRuns.length <= retention) return; const expiredRuns = successfulRuns.slice(retention); let gdriveToken = null; for (const run of expiredRuns) { try { const s3List = JSON.parse(run.s3Objects || '[]'); if (cfg.s3Enabled && s3List.length > 0) { for (const obj of s3List) { await s3DeleteObject(cfg, obj.key).catch(e => console.warn('[Retention] S3 delete warning:', e.message)); } } const gdList = JSON.parse(run.gdriveFiles || '[]'); if (cfg.gdriveEnabled && gdList.length > 0) { if (!gdriveToken) { gdriveToken = await getGoogleDriveAccessToken(cfg).catch(() => null); } if (gdriveToken) { for (const f of gdList) { await gdriveDeleteFile(gdriveToken, f.id).catch(e => console.warn('[Retention] GDrive delete warning:', e.message)); } } } db.prepare('DELETE FROM backup_history WHERE id = ?').run(run.id); } catch (pruneErr) { console.warn('[Retention] Failed pruning backup record:', pruneErr.message); } } } // Restore database from a Buffer (.db SQLite file or .json archive) without leaving local files function restoreDatabaseFromBuffer(fileBuffer, filename = '') { const isSqliteHeader = fileBuffer.length > 16 && fileBuffer.subarray(0, 15).toString('utf8') === 'SQLite format 3'; if (isSqliteHeader || filename.toLowerCase().endsWith('.db')) { const tempRestorePath = path.join( os.tmpdir(), `forkfolio_restore_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db` ); let sourceDb = null; try { fs.writeFileSync(tempRestorePath, fileBuffer); sourceDb = new Database(tempRestorePath, { readonly: true }); const users = sourceDb.prepare('SELECT * FROM users').all(); const recipes = sourceDb.prepare('SELECT * FROM recipes').all(); let shoppingLists = []; let shoppingListItems = []; let settings = []; let recipeVisits = []; let listVisits = []; try { shoppingLists = sourceDb.prepare('SELECT * FROM shopping_lists').all(); } catch (e) {} try { shoppingListItems = sourceDb.prepare('SELECT * FROM shopping_list_items').all(); } catch (e) {} try { settings = sourceDb.prepare('SELECT * FROM settings').all(); } catch (e) {} try { recipeVisits = sourceDb.prepare('SELECT * FROM recipe_shares_visits').all(); } catch (e) {} try { listVisits = sourceDb.prepare('SELECT * FROM shopping_list_shares_visits').all(); } catch (e) {} applyParsedRestoreData({ users, recipes, shoppingLists, shoppingListItems, settings, recipeSharesVisits: recipeVisits, shoppingListSharesVisits: listVisits }); return { users: users.length, recipes: recipes.length, shoppingLists: shoppingLists.length, userCount: users.length, recipeCount: recipes.length, shoppingListCount: shoppingLists.length }; } finally { if (sourceDb) { try { sourceDb.close(); } catch (e) {} } try { if (fs.existsSync(tempRestorePath)) fs.unlinkSync(tempRestorePath); } catch (e) {} } } else { // Parse as JSON archive const parsed = JSON.parse(fileBuffer.toString('utf8')); const data = parsed.data || parsed; if (!Array.isArray(data.users) && !Array.isArray(data.recipes)) { throw new Error('Invalid backup archive format: missing users and recipes tables.'); } applyParsedRestoreData(data); const uLen = (data.users || []).length; const rLen = (data.recipes || []).length; const sLen = (data.shoppingLists || []).length; return { users: uLen, recipes: rLen, shoppingLists: sLen, userCount: uLen, recipeCount: rLen, shoppingListCount: sLen }; } } function applyParsedRestoreData(data) { db.pragma('foreign_keys = OFF'); try { const tx = db.transaction(() => { if (Array.isArray(data.users) && data.users.length > 0) { db.prepare('DELETE FROM users').run(); const insUser = db.prepare(` INSERT OR REPLACE INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, defaultConsolidateLists, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); for (const u of data.users) { insUser.run( u.id, u.username, u.salt, u.passwordHash, u.isAdmin ? 1 : 0, u.isRecipeManager ? 1 : 0, u.defaultConsolidateLists !== 0 ? 1 : 0, u.createdAt || new Date().toISOString() ); } } if (Array.isArray(data.recipes)) { db.prepare('DELETE FROM recipes').run(); const insRecipe = db.prepare(` INSERT OR REPLACE INTO recipes ( id, userId, createdBy, isPublic, title, description, servings, prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const r of data.recipes) { const coverImg = r.image || r.embeddedCoverImage || ''; insRecipe.run( r.id, r.userId || 'system_admin', r.createdBy || 'gourmet_system', r.isPublic !== 0 && r.isPublic !== false ? 1 : 0, r.title || 'Untitled Recipe', r.description || '', r.servings || '', r.prepTime || '', r.cookTime || '', r.totalTime || '', coverImg, typeof r.images === 'string' ? r.images : JSON.stringify(r.images || []), typeof r.ingredients === 'string' ? r.ingredients : JSON.stringify(r.ingredients || []), typeof r.instructions === 'string' ? r.instructions : JSON.stringify(r.instructions || []), r.sourceUrl || '', r.shareToken || null, r.createdAt || new Date().toISOString(), r.updatedAt || null ); } } if (Array.isArray(data.shoppingLists)) { db.prepare('DELETE FROM shopping_lists').run(); const insList = db.prepare(` INSERT OR REPLACE INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?) `); for (const l of data.shoppingLists) { insList.run(l.id, l.userId, l.createdBy, l.name, l.shareToken || null, l.createdAt || new Date().toISOString(), l.updatedAt || null); } } if (Array.isArray(data.shoppingListItems)) { db.prepare('DELETE FROM shopping_list_items').run(); const insItem = db.prepare(` INSERT OR REPLACE INTO shopping_list_items ( id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const item of data.shoppingListItems) { insItem.run( item.id, item.listId, item.rawText, item.itemName || item.rawText, item.quantity !== undefined ? item.quantity : null, item.unit || '', item.category || 'Other', item.recipeId || null, item.recipeTitle || null, item.checked ? 1 : 0, item.createdAt || new Date().toISOString() ); } } if (Array.isArray(data.recipeSharesVisits)) { db.prepare('DELETE FROM recipe_shares_visits').run(); const insVisit = db.prepare('INSERT OR IGNORE INTO recipe_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)'); for (const v of data.recipeSharesVisits) { insVisit.run(v.shareToken, v.visitorIp, v.visitedAt); } } if (Array.isArray(data.shoppingListSharesVisits)) { db.prepare('DELETE FROM shopping_list_shares_visits').run(); const insListVisit = db.prepare('INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)'); for (const v of data.shoppingListSharesVisits) { insListVisit.run(v.shareToken, v.visitorIp, v.visitedAt); } } }); tx(); } finally { db.pragma('foreign_keys = ON'); } } // Background Schedule Checker (runs every 60 seconds) setInterval(async () => { try { const cfg = getBackupConfig(); if (!cfg || cfg.scheduleFrequency === 'off') return; if (!cfg.s3Enabled && !cfg.gdriveEnabled) return; if (isBackupRunning) return; const now = new Date(); const lastRun = cfg.lastBackupAt ? new Date(cfg.lastBackupAt) : null; const elapsedMs = lastRun ? now.getTime() - lastRun.getTime() : Infinity; let shouldRun = false; if (cfg.scheduleFrequency === '6h') { shouldRun = elapsedMs >= 6 * 60 * 60 * 1000; } else if (cfg.scheduleFrequency === '12h') { shouldRun = elapsedMs >= 12 * 60 * 60 * 1000; } else if (cfg.scheduleFrequency === 'daily' || cfg.scheduleFrequency === 'weekly') { const [targetHour, targetMin] = (cfg.scheduleTime || '02:00').split(':').map(n => parseInt(n, 10) || 0); const matchesTime = now.getHours() === targetHour && now.getMinutes() === targetMin; const minCooldown = 20 * 60 * 60 * 1000; // Prevent double-firing within same day if (matchesTime && elapsedMs >= minCooldown) { if (cfg.scheduleFrequency === 'daily') { shouldRun = true; } else if (cfg.scheduleFrequency === 'weekly' && now.getDay() === Number(cfg.scheduleDay || 0)) { shouldRun = true; } } } if (shouldRun) { console.log(`[Backup Scheduler] Running scheduled (${cfg.scheduleFrequency}) cloud backup...`); await executeCloudBackup('scheduled'); } } catch (schedErr) { console.error('[Backup Scheduler] Scheduled backup error:', schedErr.message); } }, 60 * 1000); // ------------------------------------------------------------- // ADMIN BACKUP REST API ROUTES // ------------------------------------------------------------- // Get backup configuration and cloud backup history app.get('/api/admin/backups/config', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const config = getBackupConfig(); const history = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 50').all().map(row => { const s3Objects = JSON.parse(row.s3Objects || '[]'); const gdriveFiles = JSON.parse(row.gdriveFiles || '[]'); const s3Db = s3Objects.find(o => o.format === 'db'); const s3Json = s3Objects.find(o => o.format === 'json'); const gdDb = gdriveFiles.find(f => f.format === 'db'); const gdJson = gdriveFiles.find(f => f.format === 'json'); return { ...row, s3Objects, gdriveFiles, shoppingListCount: row.listCount || 0, dbSizeBytes: Math.round((row.sizeBytes || 0) * 0.55), jsonSizeBytes: Math.round((row.sizeBytes || 0) * 0.45), s3KeyDb: s3Db ? s3Db.key : null, s3KeyJson: s3Json ? s3Json.key : null, gdriveFileIdDb: gdDb ? gdDb.id : null, gdriveFileIdJson: gdJson ? gdJson.id : null, filenameDb: `${row.label}.db`, filenameJson: `${row.label}.json`, errorMessage: row.errorDetails || '' }; }); res.json({ config, history }); } catch (error) { console.error('Error loading backup config:', error); res.status(500).json({ error: 'Failed to load backup configuration.' }); } }); // Save backup configuration & schedule app.put('/api/admin/backups/config', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const b = req.body || {}; try { const existing = getBackupConfig(); const nextSchedule = b.scheduleFrequency || b.schedule || existing.scheduleFrequency; const nextEmbed = b.embedRemoteImages !== undefined ? (b.embedRemoteImages ? 1 : 0) : b.includeRemoteImages !== undefined ? (b.includeRemoteImages ? 1 : 0) : (existing.embedRemoteImages ? 1 : 0); db.prepare(` UPDATE backup_config SET scheduleFrequency = ?, scheduleTime = ?, scheduleDay = ?, retentionCount = ?, backupFormat = ?, embedRemoteImages = ?, s3Enabled = ?, s3Endpoint = ?, s3Region = ?, s3Bucket = ?, s3Prefix = ?, s3AccessKeyId = ?, s3SecretAccessKey = ?, gdriveEnabled = ?, gdriveClientId = ?, gdriveClientSecret = ?, gdriveRefreshToken = ?, gdriveFolderId = ?, gdriveFolderName = ? WHERE id = 1 `).run( nextSchedule, b.scheduleTime || existing.scheduleTime, b.scheduleDay !== undefined ? Number(b.scheduleDay) : existing.scheduleDay, b.retentionCount !== undefined ? Number(b.retentionCount) : existing.retentionCount, b.backupFormat || existing.backupFormat, nextEmbed, b.s3Enabled !== undefined ? (b.s3Enabled ? 1 : 0) : (existing.s3Enabled ? 1 : 0), b.s3Endpoint !== undefined ? b.s3Endpoint.trim() : existing.s3Endpoint, b.s3Region !== undefined ? b.s3Region.trim() : existing.s3Region, b.s3Bucket !== undefined ? b.s3Bucket.trim() : existing.s3Bucket, b.s3Prefix !== undefined ? b.s3Prefix.trim() : existing.s3Prefix, b.s3AccessKeyId !== undefined ? b.s3AccessKeyId.trim() : existing.s3AccessKeyId, b.s3SecretAccessKey !== undefined ? b.s3SecretAccessKey.trim() : existing.s3SecretAccessKey, b.gdriveEnabled !== undefined ? (b.gdriveEnabled ? 1 : 0) : (existing.gdriveEnabled ? 1 : 0), b.gdriveClientId !== undefined ? b.gdriveClientId.trim() : existing.gdriveClientId, b.gdriveClientSecret !== undefined ? b.gdriveClientSecret.trim() : existing.gdriveClientSecret, b.gdriveRefreshToken !== undefined ? b.gdriveRefreshToken.trim() : existing.gdriveRefreshToken, b.gdriveFolderId !== undefined ? b.gdriveFolderId.trim() : existing.gdriveFolderId, b.gdriveFolderName !== undefined ? b.gdriveFolderName.trim() : existing.gdriveFolderName ); res.json({ success: true, config: getBackupConfig() }); } catch (error) { console.error('Error saving backup config:', error); res.status(500).json({ error: 'Failed to save backup configuration.' }); } }); // Test Wasabi S3 connection app.post('/api/admin/backups/test-s3', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const testCfg = { ...getBackupConfig(), ...(req.body || {}) }; await s3TestConnection(testCfg); res.json({ success: true, message: `Successfully connected to S3 bucket "${testCfg.s3Bucket}".` }); } catch (error) { res.status(400).json({ error: error.message }); } }); // Test Google Drive API connection app.post('/api/admin/backups/test-gdrive', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const testCfg = { ...getBackupConfig(), ...(req.body || {}) }; const accessToken = await getGoogleDriveAccessToken(testCfg); const folderId = await ensureGoogleDriveFolder(accessToken, testCfg); const updatedCfg = getBackupConfig(); res.json({ success: true, folderId, folderName: updatedCfg.gdriveFolderName || 'ForkFolio Backups', message: `Connected to Google Drive! Backups will be saved to folder "${updatedCfg.gdriveFolderName || 'ForkFolio Backups'}".` }); } catch (error) { res.status(400).json({ error: error.message }); } }); // Shared helper to process Google OAuth2 callback code and store tokens + default folder async function completeGoogleDriveOAuthCode(code, redirectUri) { const cfg = getBackupConfig(); const clientId = (cfg.gdriveClientId && cfg.gdriveClientId.trim()) || DEFAULT_GDRIVE_CLIENT_ID; const clientSecret = (cfg.gdriveClientSecret && cfg.gdriveClientSecret.trim()) || DEFAULT_GDRIVE_CLIENT_SECRET; const tokenParams = new URLSearchParams({ code: String(code), client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code' }); const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString() }); const tokenData = await tokenRes.json().catch(() => ({})); if (!tokenRes.ok || (!tokenData.refresh_token && !tokenData.access_token)) { throw new Error(tokenData.error_description || tokenData.error || 'Failed to exchange Google authorization code.'); } const refreshToken = tokenData.refresh_token || cfg.gdriveRefreshToken; const accessToken = tokenData.access_token; let email = cfg.gdriveAccountEmail || ''; if (accessToken) { const fetchedEmail = await getGoogleDriveUserEmail(accessToken); if (fetchedEmail) email = fetchedEmail; } db.prepare(` UPDATE backup_config SET gdriveRefreshToken = ?, gdriveEnabled = 1, gdriveAccountEmail = ? WHERE id = 1 `).run(refreshToken, email); if (accessToken) { await ensureGoogleDriveFolder(accessToken, getBackupConfig()).catch(() => {}); } } function renderOAuthSuccessHtml() { return ` Google Drive Connected

✓ Google Drive Connected!

Your Google account is now linked to ForkFolio. You can now select your backup folder in the admin panel.

This window will close automatically...

`; } // Ephemeral / persistent loopback listener on 127.0.0.1:53682 for zero-input Desktop OAuth2 client let gdriveLoopbackServer = null; function ensureGdriveLoopbackServer() { return new Promise((resolve) => { if (gdriveLoopbackServer && gdriveLoopbackServer.listening) { return resolve(`http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); } try { const srv = http.createServer(async (req, res) => { try { const reqUrl = new URL(req.url, `http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}`); const code = reqUrl.searchParams.get('code'); const error = reqUrl.searchParams.get('error'); if (error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(`

Google Drive Authorization Cancelled

${error}

`); } if (!code) { res.writeHead(200, { 'Content-Type': 'text/plain' }); return res.end('ForkFolio Google Drive OAuth Receiver'); } await completeGoogleDriveOAuthCode(code, `http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(renderOAuthSuccessHtml()); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`

Google Drive Connection Error

${err.message}

`); } }); srv.on('error', () => { resolve(null); }); srv.listen(GDRIVE_LOOPBACK_PORT, '127.0.0.1', () => { gdriveLoopbackServer = srv; resolve(`http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); }); } catch (e) { resolve(null); } }); } // Generate Google Drive OAuth2 authorization URL for one-click admin connection (zero user input required) app.post('/api/admin/backups/gdrive/auth-url', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const cfg = getBackupConfig(); const customClientId = (req.body?.clientId || cfg.gdriveClientId || '').trim(); const customClientSecret = (req.body?.clientSecret || cfg.gdriveClientSecret || '').trim(); if (customClientId && customClientSecret) { db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run( customClientId, customClientSecret ); } const effectiveClientId = customClientId || DEFAULT_GDRIVE_CLIENT_ID; let redirectUri = await ensureGdriveLoopbackServer(); if (!redirectUri || customClientId) { const origin = req.body?.origin || `http://localhost:${PORT}`; redirectUri = `${origin}/api/admin/backups/gdrive/callback`; } const authParams = new URLSearchParams({ client_id: effectiveClientId, redirect_uri: redirectUri, response_type: 'code', scope: 'https://www.googleapis.com/auth/drive', access_type: 'offline', prompt: 'consent' }); res.json({ authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`, redirectUri }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Fallback Google OAuth2 callback on main Express server app.get('/api/admin/backups/gdrive/callback', async (req, res) => { const { code, error } = req.query; if (error || !code) { return res.status(400).send(`

Google Drive Authorization Failed

${error || 'No authorization code returned.'}

`); } try { const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`; await completeGoogleDriveOAuthCode(code, redirectUri); res.send(renderOAuthSuccessHtml()); } catch (err) { res.status(500).send(`

Google Drive Connection Error

${err.message}

`); } }); // List Google Drive folders so the admin can select where backups should go app.get('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const cfg = getBackupConfig(); const accessToken = await getGoogleDriveAccessToken(cfg); if (!cfg.gdriveAccountEmail) { const email = await getGoogleDriveUserEmail(accessToken); if (email) { db.prepare('UPDATE backup_config SET gdriveAccountEmail = ? WHERE id = 1').run(email); } } const currentFolderId = await ensureGoogleDriveFolder(accessToken, cfg); const folders = await gdriveListFolders(accessToken); const updatedCfg = getBackupConfig(); res.json({ folders, currentFolderId, currentFolderName: updatedCfg.gdriveFolderName || 'ForkFolio Backups', accountEmail: updatedCfg.gdriveAccountEmail || '' }); } catch (error) { res.status(400).json({ error: error.message }); } }); // Create a new folder in Google Drive and select it as the backup destination app.post('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const { name } = req.body || {}; if (!name || !name.trim()) { return res.status(400).json({ error: 'Folder name is required.' }); } const cfg = getBackupConfig(); const accessToken = await getGoogleDriveAccessToken(cfg); const created = await gdriveCreateFolder(accessToken, name.trim()); db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( created.id, created.name ); res.json({ success: true, folder: created, config: getBackupConfig() }); } catch (error) { res.status(400).json({ error: error.message }); } }); // Immediately save the selected Google Drive destination folder app.put('/api/admin/backups/gdrive/select-folder', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const { folderId, folderName } = req.body || {}; if (!folderId) { return res.status(400).json({ error: 'Folder ID is required.' }); } db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( folderId.trim(), (folderName || 'ForkFolio Backups').trim() ); res.json({ success: true, config: getBackupConfig() }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Disconnect Google Drive account app.post('/api/admin/backups/gdrive/disconnect', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { db.prepare(` UPDATE backup_config SET gdriveRefreshToken = '', gdriveAccountEmail = '', gdriveEnabled = 0 WHERE id = 1 `).run(); res.json({ success: true, config: getBackupConfig() }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Trigger immediate manual cloud backup (uploads to S3 / Google Drive, no local files kept) app.post('/api/admin/backups/run', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const record = await executeCloudBackup('manual'); res.json({ success: true, backup: record }); } catch (error) { console.error('Manual cloud backup failed:', error); res.status(500).json({ error: error.message }); } }); // Manual Download Backup directly to Admin's Local Computer (.db or .json), zero files kept on server app.get('/api/admin/backups/download-local', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const format = req.query.format === 'json' ? 'json' : 'db'; const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); try { if (format === 'json') { const cfg = getBackupConfig(); const archive = await createJsonArchiveData(cfg ? cfg.embedRemoteImages : true); const filename = `forkfolio-backup-${stamp}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Length', String(archive.buffer.length)); return res.send(archive.buffer); } else { const dbBuf = createSqliteSnapshotBuffer(); const filename = `forkfolio-backup-${stamp}.db`; res.setHeader('Content-Type', 'application/x-sqlite3'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Length', String(dbBuf.length)); return res.send(dbBuf); } } catch (error) { console.error('Error generating local computer download backup:', error); res.status(500).json({ error: 'Failed to generate backup download.' }); } }); // Download a cloud backup file from Wasabi S3 or Google Drive to Admin's computer app.get('/api/admin/backups/:id/download-cloud', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const format = req.query.format === 'json' ? 'json' : 'db'; try { const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Backup record not found.' }); const cfg = getBackupConfig(); const s3List = JSON.parse(row.s3Objects || '[]'); const gdList = JSON.parse(row.gdriveFiles || '[]'); const s3Target = s3List.find(o => o.format === format) || s3List[0]; const gdTarget = gdList.find(f => f.format === format) || gdList[0]; let fileBuf = null; let downloadName = `${row.label}.${format}`; if (s3Target && cfg.s3Enabled) { fileBuf = await s3GetObject(cfg, s3Target.key); downloadName = s3Target.name || downloadName; } else if (gdTarget && cfg.gdriveEnabled) { const token = await getGoogleDriveAccessToken(cfg); fileBuf = await gdriveDownloadFile(token, gdTarget.id); downloadName = gdTarget.name || downloadName; } else { return res.status(400).json({ error: 'Cloud provider for this backup is not currently enabled.' }); } res.setHeader('Content-Type', downloadName.endsWith('.json') ? 'application/json' : 'application/x-sqlite3'); res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`); res.setHeader('Content-Length', String(fileBuf.length)); res.send(fileBuf); } catch (error) { console.error('Error downloading cloud backup:', error); res.status(500).json({ error: error.message }); } }); // Restore database from a Cloud Backup record (Wasabi S3 or Google Drive) app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Backup record not found.' }); const cfg = getBackupConfig(); const s3List = JSON.parse(row.s3Objects || '[]'); const gdList = JSON.parse(row.gdriveFiles || '[]'); // Prefer .db snapshot if present, otherwise .json const s3Target = s3List.find(o => o.format === 'db') || s3List[0]; const gdTarget = gdList.find(f => f.format === 'db') || gdList[0]; let fileBuf = null; let filename = ''; if (s3Target && cfg.s3Enabled) { fileBuf = await s3GetObject(cfg, s3Target.key); filename = s3Target.name || s3Target.key; } else if (gdTarget && cfg.gdriveEnabled) { const token = await getGoogleDriveAccessToken(cfg); fileBuf = await gdriveDownloadFile(token, gdTarget.id); filename = gdTarget.name || ''; } else { return res.status(400).json({ error: 'No enabled cloud provider found to retrieve this backup.' }); } const counts = restoreDatabaseFromBuffer(fileBuf, filename); res.json({ success: true, restored: counts }); } catch (error) { console.error('Error restoring from cloud backup:', error); res.status(500).json({ error: error.message }); } }); // Restore database from an uploaded file from Admin's local computer (.db or .json base64 payload) app.post('/api/admin/backups/restore-upload', requireAuth, (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const { filename, base64Data, dataBase64 } = req.body || {}; const payload = base64Data || dataBase64; if (!payload) { return res.status(400).json({ error: 'Backup file payload is required.' }); } try { const fileBuffer = Buffer.from(payload, 'base64'); const counts = restoreDatabaseFromBuffer(fileBuffer, filename || ''); res.json({ success: true, restored: counts }); } catch (error) { console.error('Error restoring uploaded backup file:', error); res.status(500).json({ error: `Restore failed: ${error.message}` }); } }); // Delete a cloud backup record and its remote objects in Wasabi S3 / Google Drive app.delete('/api/admin/backups/:id', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Backup record not found.' }); const cfg = getBackupConfig(); const s3List = JSON.parse(row.s3Objects || '[]'); const gdList = JSON.parse(row.gdriveFiles || '[]'); if (cfg.s3Enabled && s3List.length > 0) { for (const obj of s3List) { await s3DeleteObject(cfg, obj.key).catch(e => console.warn('S3 delete warning:', e.message)); } } if (cfg.gdriveEnabled && gdList.length > 0) { const token = await getGoogleDriveAccessToken(cfg).catch(() => null); if (token) { for (const f of gdList) { await gdriveDeleteFile(token, f.id).catch(e => console.warn('GDrive delete warning:', e.message)); } } } db.prepare('DELETE FROM backup_history WHERE id = ?').run(req.params.id); res.json({ success: true }); } catch (error) { console.error('Error deleting cloud backup:', error); res.status(500).json({ error: 'Failed to delete backup.' }); } }); // Launch server app.listen(PORT, () => { console.log(`Recipe Manager server is active at http://localhost:${PORT}`); });