require('dotenv').config(); const express = require('express'); const cors = require('cors'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { v4: uuidv4 } = require('uuid'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const pdfParse = require('pdf-parse'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const axios = require('axios'); const { db, run, get, all } = require('./database'); const { sendMail } = require('./mail'); const app = express(); app.use(cors()); app.use(express.json({ limit: '500mb' })); app.use(express.urlencoded({ limit: '500mb', extended: true })); const JWT_SECRET = 'viz-3d-local-secret-key-1234'; // Ensure uploads dir exists const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } app.use('/uploads', express.static(uploadsDir)); // Multer storage const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, uploadsDir); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + '-' + file.originalname); } }); const upload = multer({ storage: storage }); // --- AUTH MIDDLEWARE --- const authenticate = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ error: 'No token provided' }); const token = authHeader.split(' ')[1]; jwt.verify(token, JWT_SECRET, (err, decoded) => { if (err) return res.status(401).json({ error: 'Invalid token' }); req.user = decoded; next(); }); }; // --- ROUTES --- // 1. Auth Register app.post('/api/auth/register', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); try { const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]); if (existing) return res.status(400).json({ error: 'User already exists' }); const hash = await bcrypt.hash(password, 10); const id = uuidv4(); // First user becomes admin and is automatically active const countRow = await get('SELECT COUNT(*) as count FROM profiles'); const role = parseInt(countRow.count) === 0 ? 'admin' : 'user'; const is_active = parseInt(countRow.count) === 0 ? true : false; await run( 'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, email, hash, role, 10, 500, is_active] ); if (is_active === false) { return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' }); } const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' }); const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [id]); res.json({ status: 'active', token, profile }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 2. Auth Login app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; try { const user = await get('SELECT * FROM profiles WHERE email = ?', [email]); if (!user) return res.status(400).json({ error: 'Invalid credentials' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(400).json({ error: 'Invalid credentials' }); if (user.is_active === false) { return res.status(403).json({ error: 'Account pending admin approval. Please contact an administrator.' }); } const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' }); const profile = { id: user.id, email: user.email, role: user.role, api_credits: user.api_credits, storage_limit_mb: user.storage_limit_mb, is_active: user.is_active }; res.json({ token, profile }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 3. Auth Me app.get('/api/auth/me', authenticate, async (req, res) => { try { const profile = await get('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles WHERE id = ?', [req.user.id]); res.json({ profile }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 3.5. Get User API Keys app.get('/api/user/keys', authenticate, async (req, res) => { try { const profile = await get('SELECT api_keys FROM profiles WHERE id = ?', [req.user.id]); let keys = {}; if (profile && profile.api_keys) { try { keys = JSON.parse(profile.api_keys); } catch (e) { // ignore invalid json } } res.json({ keys }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 3.6. Update User API Keys app.put('/api/user/keys', authenticate, async (req, res) => { const { keys } = req.body; if (!keys || typeof keys !== 'object') { return res.status(400).json({ error: 'Invalid keys object' }); } try { const keysStr = JSON.stringify(keys); await run('UPDATE profiles SET api_keys = ? WHERE id = ?', [keysStr, req.user.id]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 4. Get all projects for user app.get('/api/projects', authenticate, async (req, res) => { try { const projects = await all('SELECT id, name, created_at FROM projects WHERE user_id = ?', [req.user.id]); res.json({ projects }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 5. Get project by ID app.get('/api/projects/:id', authenticate, async (req, res) => { try { const project = await get('SELECT * FROM projects WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!project) return res.status(404).json({ error: 'Project not found' }); if (project.scene_data) { project.scene_data = JSON.parse(project.scene_data); } res.json({ project }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 6. Save (create or update) project app.post('/api/projects', authenticate, async (req, res) => { const { id, name, scene_data } = req.body; const projectId = id || uuidv4(); try { const existing = await get('SELECT * FROM projects WHERE id = ?', [projectId]); if (existing) { if (existing.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); await run('UPDATE projects SET name = ?, scene_data = ? WHERE id = ?', [name, JSON.stringify(scene_data), projectId]); } else { await run('INSERT INTO projects (id, user_id, name, scene_data) VALUES (?, ?, ?, ?)', [projectId, req.user.id, name, JSON.stringify(scene_data)]); } res.json({ success: true, id: projectId }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete('/api/projects/:id', authenticate, async (req, res) => { const { id } = req.params; try { const existing = await get('SELECT * FROM projects WHERE id = ?', [id]); if (!existing) return res.status(404).json({ error: 'Not found' }); if (existing.user_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } await run('DELETE FROM projects WHERE id = ?', [id]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 7. Get users (Admin only) app.get('/api/users', authenticate, async (req, res) => { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); try { const users = await all('SELECT id, email, role, api_credits, storage_limit_mb, is_active FROM profiles'); res.json({ users }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 8. Update user (Admin only) app.put('/api/users/:id', authenticate, async (req, res) => { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); const { role, api_credits, storage_limit_mb, is_active } = req.body; try { await run('UPDATE profiles SET role = ?, api_credits = ?, storage_limit_mb = ?, is_active = ? WHERE id = ?', [role, api_credits, storage_limit_mb, is_active !== undefined ? is_active : true, req.params.id]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 9. Deduct API credit app.post('/api/users/deduct-credit', authenticate, async (req, res) => { try { const user = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]); if (user.api_credits <= 0) return res.status(400).json({ error: 'Not enough credits' }); await run('UPDATE profiles SET api_credits = api_credits - 1 WHERE id = ?', [req.user.id]); const updated = await get('SELECT api_credits FROM profiles WHERE id = ?', [req.user.id]); res.json({ api_credits: updated.api_credits }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 10. Upload file app.post('/api/upload', authenticate, upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const url = `/uploads/${req.file.filename}`; res.json({ url }); }); // 10b. Chunked Upload - Receive chunk app.post('/api/upload/chunk', authenticate, upload.single('file'), (req, res) => { const { uploadId, chunkIndex, totalChunks } = req.body; if (!req.file || !uploadId || !chunkIndex || !totalChunks) { return res.status(400).json({ error: 'Missing chunk data' }); } const tempDir = path.join(uploadsDir, 'temp', uploadId); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } const chunkPath = path.join(tempDir, chunkIndex); fs.renameSync(req.file.path, chunkPath); res.json({ success: true }); }); // 10c. Chunked Upload - Complete app.post('/api/upload/complete', authenticate, (req, res) => { const { uploadId, fileName, totalChunks } = req.body; if (!uploadId || !fileName || !totalChunks) { return res.status(400).json({ error: 'Missing completion data' }); } const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const finalFileName = uniqueSuffix + '-' + fileName; const finalPath = path.join(uploadsDir, finalFileName); const tempDir = path.join(uploadsDir, 'temp', uploadId); try { const writeStream = fs.createWriteStream(finalPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(tempDir, i.toString()); if (!fs.existsSync(chunkPath)) { throw new Error(`Chunk ${i} is missing`); } const data = fs.readFileSync(chunkPath); writeStream.write(data); } writeStream.end(); // Clean up temp dir fs.rmSync(tempDir, { recursive: true, force: true }); const url = `/uploads/${finalFileName}`; res.json({ url }); } catch (error) { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } res.status(500).json({ error: error.message }); } }); // ========================================== // SPARK PLUG WORKFLOW APIS // ========================================== const getKeysFromProfile = async (userId) => { const profile = await get('SELECT api_keys FROM profiles WHERE id = ?', [userId]); if (profile && profile.api_keys) { try { return JSON.parse(profile.api_keys); } catch(e) {} } return {}; }; // 1. Extract PDF (Gemini) app.post('/api/sparkplug/extract', authenticate, upload.single('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const keys = await getKeysFromProfile(req.user.id); const geminiKey = keys.GEMINI_API_KEY; if (!geminiKey) return res.status(400).json({ error: 'Gemini API key missing' }); // Parse PDF const dataBuffer = fs.readFileSync(req.file.path); const base64Pdf = dataBuffer.toString('base64'); // Call Gemini to extract prompt const genAI = new GoogleGenerativeAI(geminiKey); const model = genAI.getGenerativeModel({ model: "gemini-3.5-flash-lite" }); const prompt = ` You are an expert product designer. Review the attached product spec PDF (which may include text and sample images). Your goal is to create a highly detailed, concise visual design prompt for an image generation AI. CRITICAL: If there is a sample image of the product in the PDF, you MUST carefully analyze it and extract the exact HEX color codes used in the design. Include these HEX codes, as well as the exact shape, materials, typography style, and label content in your final prompt so the image generator knows exactly how it looks. Do not include background details. `; const result = await model.generateContent([ { inlineData: { data: base64Pdf, mimeType: "application/pdf" } }, prompt ]); const response = await result.response; const extractedPrompt = response.text(); res.json({ prompt: extractedPrompt }); } catch (err) { res.status(500).json({ error: err.message }); } finally { if (req.file) { fs.unlinkSync(req.file.path); // cleanup uploaded PDF } } }); // 2. Generate Images (Nano Banana / Gemini Imagen) app.post('/api/sparkplug/generate-images', authenticate, async (req, res) => { const { prompt } = req.body; if (!prompt) return res.status(400).json({ error: 'No prompt provided' }); try { const keys = await getKeysFromProfile(req.user.id); const apiKey = keys.NANO_BANANA_API_KEY || keys.GEMINI_API_KEY; if (!apiKey) return res.status(400).json({ error: 'Nano Banana (Gemini) API key missing' }); // We will use axios to call the new Gemini 3.1 REST API via /interactions const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/interactions'; const views = ['Front view', 'Back view', 'Left side view', 'Right side view']; const imageUrls = []; // Run parallel generation for all 4 views const promises = views.map(async (view, index) => { const fullPrompt = `${prompt}. ${view}, isolated on a pure white background, studio lighting.`; const payload = { model: "gemini-3.1-flash-lite-image", input: [ { type: "text", text: fullPrompt } ] }; const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, { headers: { 'Content-Type': 'application/json' } }); // The new interactions endpoint returns the interaction object. // We look for the image output. let base64Image = null; if (response.data.interaction?.output_image?.data) { base64Image = response.data.interaction.output_image.data; } else if (response.data.interaction?.output?.blocks) { // Fallback for raw REST shape if output_image is an SDK-only convenience wrapper const imgBlock = response.data.interaction.output.blocks.find(b => b.type === 'image' || b.image); if (imgBlock) { base64Image = imgBlock.image?.data || imgBlock.data; } } if (!base64Image) { throw new Error("Could not extract base64 image data from Gemini response: " + JSON.stringify(response.data)); } const buffer = Buffer.from(base64Image, 'base64'); const filename = `sparkplug-${req.user.id}-${Date.now()}-${index}.png`; const filepath = path.join(uploadsDir, filename); fs.writeFileSync(filepath, buffer); return `/uploads/${filename}`; }); const generatedUrls = await Promise.all(promises); res.json({ images: generatedUrls }); } catch (err) { // Gemini HTTP errors usually have response.data.error const errorMsg = err.response?.data?.error?.message || err.message; res.status(500).json({ error: errorMsg }); } }); // 3. Generate 3D (Meshy) app.post('/api/sparkplug/generate-3d', authenticate, async (req, res) => { const { imageUrls } = req.body; if (!imageUrls || imageUrls.length === 0) return res.status(400).json({ error: 'No images provided' }); try { const keys = await getKeysFromProfile(req.user.id); const meshyKey = keys.MESHY_API_KEY; if (!meshyKey) return res.status(400).json({ error: 'Meshy API key missing' }); // Assuming imageUrls are local paths like /uploads/... // Meshy requires base64 Data URIs if the images are not publicly accessible URLs. // Our local URLs are not publicly accessible to Meshy's servers! We MUST convert to base64. const base64Images = imageUrls.map(url => { // Extract filename from URL const filename = url.replace('/uploads/', ''); const filepath = path.join(uploadsDir, filename); const buffer = fs.readFileSync(filepath); return `data:image/png;base64,${buffer.toString('base64')}`; }); const response = await axios.post('https://api.meshy.ai/openapi/v1/multi-image-to-3d', { image_urls: base64Images, enable_pbr: true }, { headers: { 'Authorization': `Bearer ${meshyKey}`, 'Content-Type': 'application/json' } }); res.json({ taskId: response.data.result }); } catch (err) { const errorMsg = err.response?.data?.message || err.message; res.status(500).json({ error: errorMsg }); } }); // 4. Check 3D Status (Meshy) app.get('/api/sparkplug/status-3d/:taskId', authenticate, async (req, res) => { const { taskId } = req.params; try { const keys = await getKeysFromProfile(req.user.id); const meshyKey = keys.MESHY_API_KEY; if (!meshyKey) return res.status(400).json({ error: 'Meshy API key missing' }); const response = await axios.get(`https://api.meshy.ai/openapi/v1/multi-image-to-3d/${taskId}`, { headers: { 'Authorization': `Bearer ${meshyKey}` } }); res.json(response.data); } catch (err) { const errorMsg = err.response?.data?.message || err.message; res.status(500).json({ error: errorMsg }); } }); const PORT = process.env.PORT || 3005; app.listen(PORT, () => { console.log(`Backend server running on port ${PORT}`); });