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 '', geminiModel TEXT DEFAULT 'gemini-2.0-flash' ) `).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) {} try { db.prepare("ALTER TABLE backup_history ADD COLUMN userSummary TEXT NOT NULL DEFAULT '[]'").run(); } catch (e) {} try { db.prepare("ALTER TABLE settings ADD COLUMN geminiModel TEXT DEFAULT 'gemini-2.0-flash'").run(); } catch (e) {} try { db.prepare("UPDATE settings SET geminiModel = 'gemini-2.0-flash' WHERE geminiModel IS NULL OR geminiModel = ''").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, geminiModel) VALUES (?, ?)').run('', 'gemini-2.0-flash'); } // 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', type: ['application/json', 'text/plain'] })); 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'); } } } // ------------------------------------------------------------- // TikTok Recipe Extraction (Option 1: Caption & Metadata Extraction) // ------------------------------------------------------------- // Helper: Check if a URL belongs to TikTok function isTikTokUrl(url) { try { const parsed = new URL(url); const host = parsed.hostname.toLowerCase(); return host.includes('tiktok.com'); } catch (e) { return false; } } // Helper: Clean raw TikTok caption by stripping trailing hashtags and emojis function cleanTikTokCaption(raw) { if (!raw) return ''; let text = raw.replace(/(?:\s*#[^\s#]+)+\s*$/g, '').trim(); text = text.replace(/[\uFE00-\uFE0F\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u26FF\u2700-\u27BF]+$/, '').trim(); return text; } // Helper: Split single-line or unstructured social captions into readable candidate phrases function splitCaptionIntoPhrases(text) { const breakPhrases = [ 'Mix it', 'Whisk', 'Add in', 'Add the', 'Add one', 'In a separate bowl:?', 'In a separate', 'In a measuring cup', 'In a greased and floured', 'In a greased', 'In a large bowl', 'For the apple:?', 'For the glaze:?', 'For the sauce:?', 'For the dough:?', 'Then add', 'Then other', 'Then 1/2', 'Swirl it', 'Sprinkle', 'Bake at', 'Glaze:?' ]; let working = text; for (const phrase of breakPhrases) { const re = new RegExp('\\b(' + phrase + ')', 'gi'); working = working.replace(re, '\n$1'); } const qtyBreakRegex = /(?<=[a-zA-Z\)\,\.\!])\s+(?=(?:(?:\d+\s*(?:&|\+)?\s*)?(?:\d+[\/\.]\d+|[¼½¾⅓⅔⅛⅜⅝⅞]|\d+)\s*(?:cups?|c|tbsp|tablespoons?|tsp|teaspoons?|oz|ounces?|lbs?|sticks?|large|small|medium|cans?)\b))/gi; working = working.replace(qtyBreakRegex, '\n'); return working.split('\n').map(s => s.trim()).filter(Boolean); } // Helper: Heuristic parser to extract recipe structure from a social video caption function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) { const caption = cleanTikTokCaption(rawCaption); if (!caption) return null; let title = ''; let description = ''; const ingredients = []; const instructions = []; const ingHeaderMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\s*[:\-]?/i); let remainingText = caption; if (ingHeaderMatch) { const rawTitle = caption.slice(0, ingHeaderMatch.index).trim(); title = rawTitle.replace(/[:\-.]+$/, '').trim(); remainingText = caption.slice(ingHeaderMatch.index + ingHeaderMatch[0].length).trim(); } else { const firstSentence = caption.match(/^([^.!?\n]+[.!?])/); title = firstSentence ? firstSentence[1].replace(/[:\-.]+$/, '').trim() : caption.slice(0, 50).trim(); remainingText = caption.slice(title.length).trim(); } const segments = splitCaptionIntoPhrases(remainingText); const instructionStarters = /^(?:mix|whisk|add|bake|stir|fold|combine|in a|then|swirl|sprinkle|pour|heat|cook|chill|serve|place|cut|slice|dice)\b/i; const isIngredientPattern = /^(?:(?:\d+\s*(?:&|\+)?\s*)?(?:\d+[\/\.]\d+|[¼½¾⅓⅔⅛⅜⅝⅞]|\d+)\s*(?:cups?|c|tbsp|tablespoons?|tsp|teaspoons?|oz|ounces?|lbs?|sticks?|large|small|medium|cans?|pinch|pkg)?\b|[•◦⁃∙*\-+])/i; for (let seg of segments) { seg = seg.trim().replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim(); if (!seg || seg.length < 2) continue; if (instructionStarters.test(seg) || /^\d+[\.\)]\s+/.test(seg)) { instructions.push(seg.replace(/^\d+[\.\)]\s*/, '')); } else if (isIngredientPattern.test(seg)) { if (/batter|pan/i.test(seg)) { instructions.push(seg); } else { ingredients.push(seg); } } else if (/^(?:glaze|for the )/i.test(seg)) { instructions.push(seg); } else { if (/(?:bowl|pan|oven|bake|mins?|minutes?|degrees?|350|mix|batter)/i.test(seg)) { instructions.push(seg); } else { ingredients.push(seg); } } } let cookTime = ''; const bakeMatch = caption.match(/\b(?:bake(?:\s+at\s+\d+)?\s+(?:for\s+)?(\d+[\s\-to]+\d+\s*(?:mins?|minutes?|hours?|hrs?)))\b/i); if (bakeMatch) { cookTime = bakeMatch[1].trim(); } return { title: title || (author ? `Recipe by ${author}` : 'TikTok Recipe'), description: caption.slice(0, 160).trim(), image: cover || '', images: cover ? [cover] : [], servings: '', prepTime: '', cookTime, totalTime: cookTime, ingredients, instructions, sourceUrl: sourceUrl || '' }; } // Helper: Scrape recipe from a TikTok video URL using caption extraction and AI parsing async function scrapeTikTokRecipe(inputUrl) { console.log('[TikTok] Starting scrape for:', inputUrl); // 1. Resolve redirect for shortened links (vm.tiktok.com, vt.tiktok.com) let canonicalUrl = inputUrl; try { const headRes = await axios.get(inputUrl, { maxRedirects: 5, validateStatus: () => true, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' }, timeout: 8000 }); if (headRes.request?.res?.responseUrl) { canonicalUrl = headRes.request.res.responseUrl; } } catch (e) { console.warn('[TikTok] Redirect resolution note:', e.message); } // 2. Fetch page HTML (Strategy 1: Fast Axios fetch) let html = null; try { const response = await axios.get(canonicalUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', 'Referer': 'https://www.tiktok.com/' }, timeout: 12000 }); html = response.data; } catch (err) { console.warn('[TikTok] Direct fetch failed, trying Puppeteer:', err.message); } // Strategy 2: Puppeteer fallback if direct fetch failed or returned bot screen if (!html || isBotChallengePage(html)) { try { html = await fetchWithPuppeteer(canonicalUrl); } catch (pupErr) { console.error('[TikTok] Puppeteer fallback failed:', pupErr.message); } } if (!html) { throw new Error('Could not access TikTok video page. Automated access was blocked.'); } let caption = ''; let author = ''; let cover = ''; // Extract from rehydration JSON const rehydrationMatch = html.match(/ `; } // 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.' }); } }); // Discover all remote backup files directly from Google Drive and Wasabi S3 and sync with backup_history async function discoverAndSyncCloudBackups(options = {}) { const forceLiveScan = Boolean(options.liveScan); const cfg = getBackupConfig(); const snapshotMap = new Map(); // Seed from existing local backup_history first const localRows = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 100').all(); for (const row of localRows) { const s3Objects = JSON.parse(row.s3Objects || '[]'); const gdriveFiles = JSON.parse(row.gdriveFiles || '[]'); snapshotMap.set(row.label, { id: row.id, label: row.label, triggerType: row.triggerType || 'cloud', createdAt: row.createdAt, sizeBytes: row.sizeBytes || 0, userCount: row.userCount || 0, recipeCount: row.recipeCount || 0, imageCount: row.imageCount || 0, listCount: row.listCount || 0, s3Objects, gdriveFiles, status: row.status || 'success', errorDetails: row.errorDetails || null, inLocalDb: true }); } const providerErrors = []; const hasKnownCloudSnapshots = Array.from(snapshotMap.values()).some( s => (s.gdriveFiles && s.gdriveFiles.length > 0) || (s.s3Objects && s.s3Objects.length > 0) ); // Only query Google Drive / S3 APIs when explicitly requested ("Scan Cloud Now" / "Sync from Cloud") or when local history is empty if (forceLiveScan || !hasKnownCloudSnapshots) { // 1. Live scan Google Drive backup folder if connected if (cfg.gdriveConnected) { try { const accessToken = await getGoogleDriveAccessToken(cfg); const driveFiles = await gdriveListBackupFiles(accessToken, cfg.gdriveFolderId || ''); for (const df of driveFiles) { const baseLabel = df.name.replace(/\.(db|sqlite|json)$/i, ''); let entry = snapshotMap.get(baseLabel); if (!entry) { entry = { id: 'cld_gd_' + df.id.slice(0, 12), label: baseLabel, triggerType: 'cloud', createdAt: df.createdAt, sizeBytes: 0, userCount: 0, recipeCount: 0, imageCount: 0, listCount: 0, s3Objects: [], gdriveFiles: [], status: 'success', errorDetails: null, inLocalDb: false }; snapshotMap.set(baseLabel, entry); } const alreadyLinked = entry.gdriveFiles.some(g => g.id === df.id || g.format === df.format); if (!alreadyLinked) { entry.gdriveFiles.push({ id: df.id, name: df.name, format: df.format, size: df.size }); if (!entry.inLocalDb) { entry.sizeBytes += df.size || 0; } } } } catch (gdErr) { const hasCachedDriveBackups = Array.from(snapshotMap.values()).some(s => s.gdriveFiles && s.gdriveFiles.length > 0); if (!hasCachedDriveBackups) { providerErrors.push(`Google Drive: ${gdErr.message}`); } else { console.warn('[Cloud Sync] Live Google Drive scan warning (using local history index):', gdErr.message); } } } // 2. Live scan Wasabi S3 bucket if configured if (cfg.s3Enabled && cfg.s3Bucket && cfg.s3AccessKeyId && cfg.s3SecretAccessKey) { try { const s3Files = await s3ListBackupObjects(cfg); for (const sf of s3Files) { const baseLabel = sf.name.replace(/\.(db|sqlite|json)$/i, ''); let entry = snapshotMap.get(baseLabel); if (!entry) { entry = { id: 'cld_s3_' + crypto.createHash('md5').update(sf.key).digest('hex').slice(0, 10), label: baseLabel, triggerType: 'cloud', createdAt: sf.createdAt, sizeBytes: 0, userCount: 0, recipeCount: 0, imageCount: 0, listCount: 0, s3Objects: [], gdriveFiles: [], status: 'success', errorDetails: null, inLocalDb: false }; snapshotMap.set(baseLabel, entry); } const alreadyLinked = entry.s3Objects.some(o => o.key === sf.key || o.format === sf.format); if (!alreadyLinked) { entry.s3Objects.push({ key: sf.key, name: sf.name, format: sf.format, size: sf.size }); if (!entry.inLocalDb) { entry.sizeBytes += sf.size || 0; } } } } catch (s3Err) { providerErrors.push(`Wasabi S3: ${s3Err.message}`); } } } // Persist any newly discovered remote cloud snapshots into backup_history so they can be managed seamlessly const insHistory = db.prepare(` INSERT OR IGNORE INTO backup_history ( id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount, s3Objects, gdriveFiles, status, errorDetails, createdAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?) `); const updHistory = db.prepare(` UPDATE backup_history SET s3Objects = ?, gdriveFiles = ?, sizeBytes = CASE WHEN sizeBytes = 0 THEN ? ELSE sizeBytes END WHERE id = ? `); const allSnapshots = Array.from(snapshotMap.values()).sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); for (const snap of allSnapshots) { if (!snap.inLocalDb && (snap.gdriveFiles.length > 0 || snap.s3Objects.length > 0)) { insHistory.run( snap.id, snap.label, snap.triggerType, snap.sizeBytes, snap.userCount, snap.recipeCount, snap.imageCount, snap.listCount, JSON.stringify(snap.s3Objects), JSON.stringify(snap.gdriveFiles), snap.createdAt ); } else if (snap.inLocalDb) { updHistory.run( JSON.stringify(snap.s3Objects), JSON.stringify(snap.gdriveFiles), snap.sizeBytes, snap.id ); } } const enriched = allSnapshots.map(snap => { const s3Db = snap.s3Objects.find(o => o.format === 'db'); const s3Json = snap.s3Objects.find(o => o.format === 'json'); const gdDb = snap.gdriveFiles.find(f => f.format === 'db'); const gdJson = snap.gdriveFiles.find(f => f.format === 'json'); const dbSize = (gdDb?.size || s3Db?.size) || Math.round((snap.sizeBytes || 0) * 0.55); const jsonSize = (gdJson?.size || s3Json?.size) || Math.round((snap.sizeBytes || 0) * 0.45); return { ...snap, shoppingListCount: snap.listCount || 0, dbSizeBytes: dbSize, jsonSizeBytes: jsonSize, s3KeyDb: s3Db ? s3Db.key : null, s3KeyJson: s3Json ? s3Json.key : null, gdriveFileIdDb: gdDb ? gdDb.id : null, gdriveFileIdJson: gdJson ? gdJson.id : null, filenameDb: (gdDb?.name || s3Db?.name) || `${snap.label}.db`, filenameJson: (gdJson?.name || s3Json?.name) || `${snap.label}.json`, errorMessage: snap.errorDetails || '' }; }); return { snapshots: enriched, providerErrors }; } // Live scan & list cloud backups from Google Drive and Wasabi S3 app.get('/api/admin/backups/cloud-files', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } try { const liveScan = req.query.scan === '1' || req.query.live === '1'; const result = await discoverAndSyncCloudBackups({ liveScan }); res.json({ backups: result.snapshots.filter(s => s.gdriveFiles.length > 0 || s.s3Objects.length > 0), providerErrors: result.providerErrors, config: getBackupConfig() }); } catch (error) { console.error('Error listing cloud backup files:', error); res.status(500).json({ error: error.message }); } }); // In-memory cache of recently created/downloaded backup buffers (RAM only, zero disk storage) const inMemoryBackupBuffers = new Map(); function rememberBackupBufferInMemory(key, buffer, filename) { if (!key || !buffer) return; if (inMemoryBackupBuffers.size >= 12 && !inMemoryBackupBuffers.has(key)) { const oldestKey = inMemoryBackupBuffers.keys().next().value; if (oldestKey) inMemoryBackupBuffers.delete(oldestKey); } inMemoryBackupBuffers.set(key, { buffer, filename: filename || '', expiresAt: Date.now() + 60 * 60 * 1000 }); } function getRememberedBackupBuffer(key) { const entry = inMemoryBackupBuffers.get(key); if (!entry) return null; if (entry.expiresAt < Date.now()) { inMemoryBackupBuffers.delete(key); return null; } return entry; } function getLiveDatabaseUserInspection() { 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(); return inspectBackupUsersFromData({ users, recipes, shoppingLists }); } // Inspect users and item counts inside a cloud or uploaded backup before restoring app.post('/api/admin/backups/inspect', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const { provider, fileId, objectKey, historyId, format, filename, base64Data, dataBase64 } = req.body || {}; try { const rawBase64 = base64Data || dataBase64; if (rawBase64) { const fileBuf = Buffer.from(rawBase64, 'base64'); const parsedData = parseBackupBufferToData(fileBuf, filename || 'backup.db'); const inspection = inspectBackupUsersFromData(parsedData); return res.json({ success: true, ...inspection }); } // Check if the buffer is already cached in RAM let cachedEntry = null; if (provider === 'gdrive' && fileId) { cachedEntry = getRememberedBackupBuffer(`gdrive:${fileId}`); } else if (provider === 's3' && objectKey) { cachedEntry = getRememberedBackupBuffer(`s3:${objectKey}`); } else if (historyId) { const preferredFormat = format === 'json' ? 'json' : 'db'; cachedEntry = getRememberedBackupBuffer(`history:${historyId}:${preferredFormat}`); } if (cachedEntry && cachedEntry.buffer) { const parsedData = parseBackupBufferToData(cachedEntry.buffer, cachedEntry.filename || filename || 'backup.db'); const inspection = inspectBackupUsersFromData(parsedData); return res.json({ success: true, ...inspection }); } // Look up stored userSummary metadata in backup_history so we don't need to download the full cloud file just to list users let matchedRow = null; if (historyId) { matchedRow = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(historyId); } else if (fileId) { matchedRow = db.prepare('SELECT * FROM backup_history WHERE gdriveFiles LIKE ? ORDER BY createdAt DESC LIMIT 1').get(`%${fileId}%`); } else if (objectKey) { matchedRow = db.prepare('SELECT * FROM backup_history WHERE s3Objects LIKE ? ORDER BY createdAt DESC LIMIT 1').get(`%${objectKey}%`); } if (matchedRow) { let storedUsers = []; try { storedUsers = JSON.parse(matchedRow.userSummary || '[]'); } catch (e) {} if (Array.isArray(storedUsers) && storedUsers.length > 0) { return res.json({ success: true, users: storedUsers, totalUsers: matchedRow.userCount || storedUsers.length, totalRecipes: matchedRow.recipeCount || storedUsers.reduce((sum, u) => sum + (u.recipeCount || 0), 0), totalShoppingLists: matchedRow.listCount || storedUsers.reduce((sum, u) => sum + (u.shoppingListCount || 0), 0) }); } // Backfill userSummary for older backup_history rows created before userSummary column existed const liveInspection = getLiveDatabaseUserInspection(); db.prepare('UPDATE backup_history SET userSummary = ? WHERE id = ?').run( JSON.stringify(liveInspection.users), matchedRow.id ); return res.json({ success: true, users: liveInspection.users, totalUsers: matchedRow.userCount || liveInspection.totalUsers, totalRecipes: matchedRow.recipeCount || liveInspection.totalRecipes, totalShoppingLists: matchedRow.listCount || liveInspection.totalShoppingLists }); } // Fallback to live database user list if no history row matched const fallbackInspection = getLiveDatabaseUserInspection(); return res.json({ success: true, ...fallbackInspection }); } catch (error) { console.error('Error inspecting backup:', error); res.status(500).json({ error: error.message }); } }); // Restore directly from a specified cloud file (Google Drive fileId or Wasabi S3 objectKey) app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res) => { if (!req.user.isAdmin) { return res.status(403).json({ error: 'Administrator privileges required.' }); } const { provider, fileId, objectKey, filename, targetUser } = req.body || {}; try { const cfg = getBackupConfig(); let fileBuf = null; let resolvedName = filename || 'cloud-backup.db'; if (provider === 'gdrive' && fileId) { const cacheKey = `gdrive:${fileId}`; const cached = getRememberedBackupBuffer(cacheKey); if (cached && cached.buffer) { fileBuf = cached.buffer; resolvedName = cached.filename || resolvedName; } else { const token = await getGoogleDriveAccessToken(cfg); fileBuf = await gdriveDownloadFile(token, fileId); rememberBackupBufferInMemory(cacheKey, fileBuf, resolvedName); } } else if (provider === 's3' && objectKey) { const cacheKey = `s3:${objectKey}`; const cached = getRememberedBackupBuffer(cacheKey); if (cached && cached.buffer) { fileBuf = cached.buffer; resolvedName = cached.filename || resolvedName; } else { fileBuf = await s3GetObject(cfg, objectKey); resolvedName = filename || objectKey; rememberBackupBufferInMemory(cacheKey, fileBuf, resolvedName); } } else { return res.status(400).json({ error: 'Valid cloud provider and file identifier are required.' }); } const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName, targetUser || 'all'); res.json({ success: true, restored: counts }); } catch (error) { console.error('Error restoring from direct cloud file:', error); res.status(500).json({ error: error.message }); } }); // 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}`; const cachedHist = getRememberedBackupBuffer(`history:${req.params.id}:${format}`); if (cachedHist && cachedHist.buffer) { fileBuf = cachedHist.buffer; downloadName = cachedHist.filename || downloadName; } else if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) { const cachedGd = getRememberedBackupBuffer(`gdrive:${gdTarget.id}`); if (cachedGd && cachedGd.buffer) { fileBuf = cachedGd.buffer; downloadName = gdTarget.name || downloadName; } else { const token = await getGoogleDriveAccessToken(cfg); fileBuf = await gdriveDownloadFile(token, gdTarget.id); downloadName = gdTarget.name || downloadName; rememberBackupBufferInMemory(`gdrive:${gdTarget.id}`, fileBuf, downloadName); } } else if (s3Target && cfg.s3Enabled) { const tokenBuf = await s3GetObject(cfg, s3Target.key); fileBuf = tokenBuf; downloadName = s3Target.name || downloadName; rememberBackupBufferInMemory(`s3:${s3Target.key}`, fileBuf, downloadName); } else { return res.status(400).json({ error: 'Cloud provider for this backup is not currently connected.' }); } 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.' }); } const preferredFormat = req.body?.format === 'json' ? 'json' : 'db'; const targetUser = req.body?.targetUser || 'all'; 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 === preferredFormat) || s3List.find(o => o.format === 'db') || s3List[0]; const gdTarget = gdList.find(f => f.format === preferredFormat) || gdList.find(f => f.format === 'db') || gdList[0]; let fileBuf = null; let filename = ''; const cacheKey = `history:${req.params.id}:${preferredFormat}`; const cachedHist = getRememberedBackupBuffer(cacheKey); const cachedGd = gdTarget ? getRememberedBackupBuffer(`gdrive:${gdTarget.id}`) : null; if (cachedHist && cachedHist.buffer) { fileBuf = cachedHist.buffer; filename = cachedHist.filename || ''; } else if (cachedGd && cachedGd.buffer) { fileBuf = cachedGd.buffer; filename = gdTarget.name || cachedGd.filename || ''; } else if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) { const token = await getGoogleDriveAccessToken(cfg); fileBuf = await gdriveDownloadFile(token, gdTarget.id); filename = gdTarget.name || ''; rememberBackupBufferInMemory(`gdrive:${gdTarget.id}`, fileBuf, filename); rememberBackupBufferInMemory(cacheKey, fileBuf, filename); } else if (s3Target && cfg.s3Enabled) { fileBuf = await s3GetObject(cfg, s3Target.key); filename = s3Target.name || s3Target.key; rememberBackupBufferInMemory(`s3:${s3Target.key}`, fileBuf, filename); rememberBackupBufferInMemory(cacheKey, fileBuf, filename); } else { return res.status(400).json({ error: 'No connected cloud provider found to retrieve this backup.' }); } const counts = restoreDatabaseFromBuffer(fileBuf, filename, targetUser); 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, targetUser } = 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 || '', targetUser || 'all'); 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 || cfg.gdriveConnected) && 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}`); });