mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
3324 lines
123 KiB
JavaScript
3324 lines
123 KiB
JavaScript
const express = require('express');
|
|
const axios = require('axios');
|
|
const cheerio = require('cheerio');
|
|
const cors = require('cors');
|
|
const fs = require('fs');
|
|
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();
|
|
}
|
|
|
|
// 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('<br>')) {
|
|
const lines = html.split(/<br\s*\/?>/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)
|
|
// -------------------------------------------------------------
|
|
|
|
function getBackupConfig() {
|
|
const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get();
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
embedRemoteImages: row.embedRemoteImages === 1,
|
|
s3Enabled: row.s3Enabled === 1,
|
|
gdriveEnabled: row.gdriveEnabled === 1
|
|
};
|
|
}
|
|
|
|
// 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) {
|
|
if (!cfg.gdriveClientId || !cfg.gdriveClientSecret || !cfg.gdriveRefreshToken) {
|
|
throw new Error('Google Drive Client ID, Client Secret, and Refresh Token are required.');
|
|
}
|
|
const params = new URLSearchParams({
|
|
client_id: cfg.gdriveClientId.trim(),
|
|
client_secret: cfg.gdriveClientSecret.trim(),
|
|
refresh_token: cfg.gdriveRefreshToken.trim(),
|
|
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 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) 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;
|
|
db.prepare('UPDATE backup_config SET gdriveFolderId = ? WHERE id = 1').run(folderId);
|
|
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 = ? WHERE id = 1').run(created.id);
|
|
return created.id;
|
|
}
|
|
|
|
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
|
|
};
|
|
} 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);
|
|
return {
|
|
users: (data.users || []).length,
|
|
recipes: (data.recipes || []).length,
|
|
shoppingLists: (data.shoppingLists || []).length
|
|
};
|
|
}
|
|
}
|
|
|
|
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 => ({
|
|
...row,
|
|
s3Objects: JSON.parse(row.s3Objects || '[]'),
|
|
gdriveFiles: JSON.parse(row.gdriveFiles || '[]')
|
|
}));
|
|
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();
|
|
db.prepare(`
|
|
UPDATE backup_config
|
|
SET scheduleFrequency = ?,
|
|
scheduleTime = ?,
|
|
scheduleDay = ?,
|
|
retentionCount = ?,
|
|
backupFormat = ?,
|
|
embedRemoteImages = ?,
|
|
s3Enabled = ?,
|
|
s3Endpoint = ?,
|
|
s3Region = ?,
|
|
s3Bucket = ?,
|
|
s3Prefix = ?,
|
|
s3AccessKeyId = ?,
|
|
s3SecretAccessKey = ?,
|
|
gdriveEnabled = ?,
|
|
gdriveClientId = ?,
|
|
gdriveClientSecret = ?,
|
|
gdriveRefreshToken = ?,
|
|
gdriveFolderId = ?
|
|
WHERE id = 1
|
|
`).run(
|
|
b.scheduleFrequency || existing.scheduleFrequency,
|
|
b.scheduleTime || existing.scheduleTime,
|
|
b.scheduleDay !== undefined ? Number(b.scheduleDay) : existing.scheduleDay,
|
|
b.retentionCount !== undefined ? Number(b.retentionCount) : existing.retentionCount,
|
|
b.backupFormat || existing.backupFormat,
|
|
b.embedRemoteImages !== undefined ? (b.embedRemoteImages ? 1 : 0) : (existing.embedRemoteImages ? 1 : 0),
|
|
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
|
|
);
|
|
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);
|
|
res.json({
|
|
success: true,
|
|
folderId,
|
|
message: `Successfully connected to Google Drive API (Folder ID: ${folderId}).`
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Generate Google Drive OAuth2 authorization URL for one-click admin connection
|
|
app.post('/api/admin/backups/gdrive/auth-url', requireAuth, (req, res) => {
|
|
if (!req.user.isAdmin) {
|
|
return res.status(403).json({ error: 'Administrator privileges required.' });
|
|
}
|
|
const { clientId, clientSecret, origin } = req.body || {};
|
|
if (!clientId || !clientSecret) {
|
|
return res.status(400).json({ error: 'Please enter your Google OAuth Client ID and Client Secret first.' });
|
|
}
|
|
|
|
db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run(
|
|
clientId.trim(),
|
|
clientSecret.trim()
|
|
);
|
|
|
|
const redirectUri = `${origin || `http://localhost:${PORT}`}/api/admin/backups/gdrive/callback`;
|
|
const authParams = new URLSearchParams({
|
|
client_id: clientId.trim(),
|
|
redirect_uri: redirectUri,
|
|
response_type: 'code',
|
|
scope: 'https://www.googleapis.com/auth/drive.file',
|
|
access_type: 'offline',
|
|
prompt: 'consent'
|
|
});
|
|
|
|
res.json({
|
|
authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`,
|
|
redirectUri
|
|
});
|
|
});
|
|
|
|
// Google OAuth2 callback to automatically capture and save Refresh Token
|
|
app.get('/api/admin/backups/gdrive/callback', async (req, res) => {
|
|
const { code, error } = req.query;
|
|
if (error || !code) {
|
|
return res.status(400).send(`<h3>Google Drive Authorization Failed</h3><p>${error || 'No authorization code returned.'}</p>`);
|
|
}
|
|
|
|
try {
|
|
const cfg = getBackupConfig();
|
|
const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`;
|
|
const tokenParams = new URLSearchParams({
|
|
code: String(code),
|
|
client_id: cfg.gdriveClientId,
|
|
client_secret: cfg.gdriveClientSecret,
|
|
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 authorization code.');
|
|
}
|
|
|
|
if (tokenData.refresh_token) {
|
|
db.prepare('UPDATE backup_config SET gdriveRefreshToken = ?, gdriveEnabled = 1 WHERE id = 1').run(tokenData.refresh_token);
|
|
}
|
|
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Google Drive Connected</title></head>
|
|
<body style="font-family: sans-serif; background: #0f172a; color: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0;">
|
|
<h2 style="color: #10b981;">✓ Google Drive Connected Successfully!</h2>
|
|
<p>Your refresh token has been saved to ForkFolio. You can close this window.</p>
|
|
<script>
|
|
if (window.opener) {
|
|
window.opener.postMessage({ type: 'GDRIVE_OAUTH_SUCCESS' }, '*');
|
|
setTimeout(() => window.close(), 1500);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
} catch (err) {
|
|
res.status(500).send(`<h3>Google Drive Connection Error</h3><p>${err.message}</p>`);
|
|
}
|
|
});
|
|
|
|
// 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 } = req.body || {};
|
|
if (!base64Data) {
|
|
return res.status(400).json({ error: 'Backup file payload is required.' });
|
|
}
|
|
try {
|
|
const fileBuffer = Buffer.from(base64Data, '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}`);
|
|
});
|