545 lines
20 KiB
JavaScript
545 lines
20 KiB
JavaScript
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 (Gemini 3.1 Flash Image)
|
|
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.GEMINI_API_KEY || keys.NANO_BANANA_API_KEY;
|
|
if (!apiKey) return res.status(400).json({ error: 'Gemini API key missing' });
|
|
|
|
// Use the Gemini 3.1 Flash Image API
|
|
const baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image:generateContent';
|
|
|
|
// Intelligently determine needed views using a text model
|
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
const textModel = genAI.getGenerativeModel({ model: "gemini-3.5-flash-lite" });
|
|
const viewPrompt = `You are a 3D modeling assistant. The user wants to generate orthographic views for a product based on this description: "${prompt}".
|
|
If the product is extremely thin and flat (like a sachet, pouch, packet, paper, or card), we only need the front and back views.
|
|
Otherwise, for a standard 3D volume (bottle, box, can, etc), we need front, back, left, and right.
|
|
Return ONLY a valid JSON array of strings representing the views needed.
|
|
Example 1: ["Front view", "Back view"]
|
|
Example 2: ["Front view", "Back view", "Left side view", "Right side view"]`;
|
|
|
|
let views = ['Front view', 'Back view', 'Left side view', 'Right side view'];
|
|
try {
|
|
const viewResult = await textModel.generateContent(viewPrompt);
|
|
const textResponse = viewResult.response.text().trim();
|
|
const match = textResponse.match(/\[.*\]/s);
|
|
if (match) {
|
|
views = JSON.parse(match[0]);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to intelligently determine views, falling back to 4 views", e);
|
|
}
|
|
|
|
// Run parallel generation for determined views
|
|
const promises = views.map(async (view, index) => {
|
|
let backViewSpecifics = "";
|
|
if (view.toLowerCase().includes('back')) {
|
|
backViewSpecifics = "\n4. CRITICAL: This is the REAR / BACK view. It MUST look completely different from the front. It should show typical back-of-packaging elements like a barcode, ingredients list, usage instructions, and small regulatory text. DO NOT just generate the front logo again.";
|
|
}
|
|
|
|
const fullPrompt = `A clean, isolated 3D render concept art of: ${prompt}.
|
|
Focus ONLY on the object itself.
|
|
View: ${view}.
|
|
CRITICAL INSTRUCTIONS:
|
|
1. Pure white background.
|
|
2. DO NOT include any infographics, extra text, labels, icons, or background scenery. Just the isolated object.
|
|
3. If this object is very thin (like a sachet, pouch, or packet), the 'Left side view' and 'Right side view' must only show the thin edge profile of the object without the main front artwork.${backViewSpecifics}`;
|
|
|
|
const payload = {
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{ text: fullPrompt }
|
|
]
|
|
}
|
|
],
|
|
generationConfig: {
|
|
responseModalities: ["IMAGE"]
|
|
}
|
|
};
|
|
|
|
const response = await axios.post(`${baseUrl}?key=${apiKey}`, payload, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
let base64Image = null;
|
|
let mimeType = 'image/jpeg';
|
|
|
|
// Extract from standard Gemini generateContent response
|
|
const candidate = response.data?.candidates?.[0];
|
|
if (candidate?.content?.parts) {
|
|
for (const part of candidate.content.parts) {
|
|
if (part.inlineData && part.inlineData.data) {
|
|
base64Image = part.inlineData.data;
|
|
mimeType = part.inlineData.mimeType || 'image/jpeg';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ext = mimeType === 'image/png' ? 'png' : 'jpg';
|
|
const filename = `sparkplug-${req.user.id}-${Date.now()}-${index}.${ext}`;
|
|
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) {
|
|
let errorMsg = err.message;
|
|
if (err.response && err.response.data) {
|
|
errorMsg = typeof err.response.data === 'object' ? JSON.stringify(err.response.data) : err.response.data;
|
|
}
|
|
console.error("Gemini Image Gen Error:", errorMsg);
|
|
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}`);
|
|
});
|