import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import fs from 'fs/promises'; import { existsSync } from 'fs'; import crypto from 'crypto'; import { execFile } from 'child_process'; import util from 'util'; import { GoogleGenerativeAI } from '@google/generative-ai'; import { GoogleAIFileManager } from '@google/generative-ai/server'; const execFilePromise = util.promisify(execFile); dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const RECIPES_DIR = path.join(__dirname, 'recipes'); const TEMP_DIR = path.join(__dirname, 'temp_ingest'); // Ensure directories exist async function ensureDirs() { try { await fs.access(RECIPES_DIR); } catch { await fs.mkdir(RECIPES_DIR); } try { await fs.access(TEMP_DIR); } catch { await fs.mkdir(TEMP_DIR); } } ensureDirs(); const app = express(); app.use(express.json()); // Serve static frontend files from Vite build app.use(express.static(path.join(__dirname, 'dist'))); // Secure credentials strictly from environment variables const VALID_USER = process.env.APP_USERNAME; const VALID_PASS = process.env.APP_PASSWORD; const GEMINI_API_KEY = process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY; app.post('/api/login', (req, res) => { const { username, password } = req.body; if (username === VALID_USER && password === VALID_PASS) { res.json({ success: true }); } else { res.status(401).json({ error: 'Invalid credentials' }); } }); app.post('/api/generate', async (req, res) => { const { username, password, userPrompt, systemPrompt } = req.body; if (username !== VALID_USER || password !== VALID_PASS) { return res.status(401).json({ error: 'Unauthorized' }); } if (!GEMINI_API_KEY) { return res.status(500).json({ error: 'API key not configured on server' }); } try { const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ systemInstruction: { parts: [{ text: systemPrompt }] }, contents: [{ parts: [{ text: userPrompt }] }], generationConfig: { responseMimeType: "application/json" } }) }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.error?.message || `API error ${resp.status}`); } res.json(data); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/ingest', async (req, res) => { const { username, password, url } = req.body; if (username !== VALID_USER || password !== VALID_PASS) { return res.status(401).json({ error: 'Unauthorized' }); } if (!GEMINI_API_KEY) { return res.status(500).json({ error: 'API key not configured on server' }); } if (!url) return res.status(400).json({ error: 'Reel URL is required' }); const jobId = crypto.randomBytes(8).toString('hex'); const jobDir = path.join(TEMP_DIR, jobId); try { await fs.mkdir(jobDir, { recursive: true }); console.log(`[Ingest ${jobId}] Step 1: Running yt-dlp on ${url}`); const videoOutputTemplate = path.join(jobDir, 'video.%(ext)s'); const descFile = path.join(jobDir, 'video.description'); await execFilePromise('yt-dlp', [ '-f', 'best[ext=mp4]/best', '--no-playlist', '-o', videoOutputTemplate, '--write-description', url ]); const dirFiles = await fs.readdir(jobDir); const videoFile = dirFiles.find(f => /^video\.(mp4|webm|mkv|mov|avi)$/i.test(f)); if (!videoFile) throw new Error('yt-dlp did not produce a video file'); const videoPath = path.join(jobDir, videoFile); const ext = path.extname(videoFile).slice(1).toLowerCase(); const MIME_MAP = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo' }; const videoMimeType = MIME_MAP[ext] || 'video/mp4'; let captionText = ''; if (existsSync(descFile)) captionText = await fs.readFile(descFile, 'utf-8'); console.log(`[Ingest ${jobId}] Step 2: Uploading file to Gemini (${videoFile})...`); const fileManager = new GoogleAIFileManager(GEMINI_API_KEY); const uploadResult = await fileManager.uploadFile(videoPath, { mimeType: videoMimeType, displayName: `Reel Ingest ${jobId}`, }); let file = await fileManager.getFile(uploadResult.file.name); console.log(`[Ingest ${jobId}] Uploaded ${file.displayName} as: ${file.uri}`); // Wait for the video processing to complete on Google's side while (file.state === "PROCESSING") { process.stdout.write("."); await new Promise((resolve) => setTimeout(resolve, 5000)); file = await fileManager.getFile(uploadResult.file.name); } if (file.state === "FAILED") { throw new Error("Video processing failed inside Gemini."); } console.log(`\n[Ingest ${jobId}] Step 3: Sending prompt to Gemini...`); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const systemPrompt = "You are a professional chef. You consume video, audio, and captions from a recipe reel, and output structured JSON. Required keys: title (string), description (string), servings (number), prepTime (string), cookTime (string), ingredients (array of {amount, unit, name} objects), steps (array of {title, instruction} objects), and notes (string)."; const result = await model.generateContent({ contents: [ { role: 'user', parts: [ { fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } }, { text: "Extract the recipe (ingredients, measurements, and steps) into the required JSON format." }, ...(captionText.trim() ? [{ text: `Original Social Media Caption:\n"${captionText}"\n` }] : []) ] } ], systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] }, generationConfig: { responseMimeType: "application/json", temperature: 0.2 } }); const recipeRaw = JSON.parse(result.response.text()); // Save securely const recipeId = crypto.randomBytes(8).toString('hex'); await fs.writeFile(path.join(RECIPES_DIR, `${recipeId}.json`), JSON.stringify(recipeRaw, null, 2)); // Cleanup local temp dir and remote API file await fs.rm(jobDir, { recursive: true, force: true }); await fileManager.deleteFile(uploadResult.file.name); res.json({ id: recipeId, recipe: recipeRaw }); } catch (err) { console.error(`[Ingest ${jobId}] Failed`, err); // Try to cleanup jobDir just in case try { await fs.rm(jobDir, { recursive: true, force: true }); } catch { } res.status(500).json({ error: err.message }); } }); app.post('/api/recipes', async (req, res) => { const { username, password, recipe } = req.body; if (username !== VALID_USER || password !== VALID_PASS) { return res.status(401).json({ error: 'Unauthorized' }); } try { const id = crypto.randomBytes(8).toString('hex'); await fs.writeFile(path.join(RECIPES_DIR, `${id}.json`), JSON.stringify(recipe)); res.json({ id }); } catch (err) { res.status(500).json({ error: 'Failed to save recipe' }); } }); app.get('/api/recipes', async (req, res) => { // Pass auth via headers const username = req.headers['x-username']; const password = req.headers['x-password']; if (username !== VALID_USER || password !== VALID_PASS) { return res.status(401).json({ error: 'Unauthorized' }); } try { const files = await fs.readdir(RECIPES_DIR); const recipes = []; for (const file of files) { if (file.endsWith('.json')) { const content = await fs.readFile(path.join(RECIPES_DIR, file), 'utf-8'); const parsed = JSON.parse(content); parsed.id = file.replace('.json', ''); recipes.push(parsed); } } res.json(recipes); } catch (err) { res.status(500).json({ error: 'Failed to fetch recipes' }); } }); app.get('/api/recipes/:id', async (req, res) => { // Public route - anyone with the ID can view try { const recipeId = req.params.id.replace(/[^a-z0-9]/gi, ''); // basic sanitization const content = await fs.readFile(path.join(RECIPES_DIR, `${recipeId}.json`), 'utf-8'); res.json(JSON.parse(content)); } catch (err) { res.status(404).json({ error: 'Recipe not found' }); } }); // For any other route, send the frontend app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Secure proxy server running on port ${PORT}`); });