Files
titan3d_react_node_sqllite/backend/server.js
T
2026-09-14 21:14:00 +05:30

590 lines
22 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, async (req, res) => {
const { fileUrl } = req.body;
if (!fileUrl) return res.status(400).json({ error: 'No fileUrl provided' });
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' });
// Read PDF from the local file system
// fileUrl is something like '/uploads/1788251962641-55866782-file.pdf'
const fileName = fileUrl.replace('/uploads/', '');
const localFilePath = path.join(uploadsDir, fileName);
if (!fs.existsSync(localFilePath)) {
return res.status(404).json({ error: 'File not found on server' });
}
const dataBuffer = fs.readFileSync(localFilePath);
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 (fileUrl) {
const fileName = fileUrl.replace('/uploads/', '');
const localFilePath = path.join(uploadsDir, fileName);
if (fs.existsSync(localFilePath)) {
fs.unlinkSync(localFilePath); // 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.
4. CRITICAL: Maintain EXACTLY the same physical dimensions, shape, and aspect ratio across all views. The product's height and width MUST be consistent. Do not change between landscape and portrait orientations.
5. CRITICAL: ONLY generate EXACTLY ONE single product item in the image. Do NOT generate multiple items, groups, arrays, or side-by-side variations.${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,
topology: 'quad',
target_polycount: 50000,
ai_model: 'meshy-6',
hd_texture: true,
model_type: 'standard'
}, {
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}`
}
});
const data = response.data;
// If generation succeeded, intercept and proxy download the model to local storage to bypass CORS
if (data.status === 'SUCCEEDED' && data.model_urls && data.model_urls.glb) {
const glbUrl = data.model_urls.glb;
const localFilename = `${taskId}.glb`;
const localFilepath = path.join(uploadsDir, localFilename);
// Only download if we haven't already
if (!fs.existsSync(localFilepath)) {
try {
const fileRes = await axios.get(glbUrl, { responseType: 'arraybuffer' });
fs.writeFileSync(localFilepath, fileRes.data);
} catch (downloadErr) {
console.error("Failed to proxy download Meshy GLB:", downloadErr);
}
}
if (fs.existsSync(localFilepath)) {
// Overwrite the returned url with our local proxy url
const serverUrl = `${req.protocol}://${req.get('host')}`;
data.model_urls.glb = `${serverUrl}/uploads/${localFilename}`;
}
}
res.json(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}`);
});