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 pdfData = await pdfParse(dataBuffer); const textContent = pdfData.text; // 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. Read the following text from a product spec PDF and create a highly detailed, concise visual design prompt for an image generation AI. Focus on the object's shape, color, materials, packaging details, and label content. Do not include background details. TEXT: ${textContent.substring(0, 30000)} // Limit to avoid context length issues if massive `; const result = await model.generateContent(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 Gemini Imagen REST API const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite-image:predict'; 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 = { instances: [ { prompt: fullPrompt } ], parameters: { sampleCount: 1, outputOptions: { mimeType: 'image/png' } } }; const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, { headers: { 'Content-Type': 'application/json' } }); const base64Image = response.data.predictions[0].bytesBase64Encoded; 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}`); });