316 lines
10 KiB
JavaScript
316 lines
10 KiB
JavaScript
if (process.stdout._handle && typeof process.stdout._handle.setBlocking === 'function') {
|
|
process.stdout._handle.setBlocking(true);
|
|
}
|
|
if (process.stderr._handle && typeof process.stderr._handle.setBlocking === 'function') {
|
|
process.stderr._handle.setBlocking(true);
|
|
}
|
|
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const bcrypt = require('bcrypt');
|
|
const jwt = require('jsonwebtoken');
|
|
const pool = require('./db');
|
|
require('dotenv').config();
|
|
|
|
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your_jwt_secret_here')) {
|
|
console.error("FATAL: JWT_SECRET environment variable is not securely set in production!");
|
|
process.exit(1);
|
|
}
|
|
|
|
const rateLimit = require('express-rate-limit');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 5000;
|
|
|
|
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'http://localhost:5173';
|
|
app.use(cors({
|
|
origin: allowedOrigin
|
|
}));
|
|
app.use(express.json());
|
|
|
|
// Rate limiter for authentication routes
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 100, // Limit each IP to 100 requests per window
|
|
message: { error: 'Too many authentication attempts. Please try again later.' }
|
|
});
|
|
|
|
// Request logger middleware
|
|
app.use((req, res, next) => {
|
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
|
if (req.body && Object.keys(req.body).length > 0) {
|
|
const logBody = { ...req.body };
|
|
if (logBody.password) logBody.password = '[REDACTED]';
|
|
console.log(' Body:', JSON.stringify(logBody));
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Middleware to verify JWT token
|
|
const authenticateToken = (req, res, next) => {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (!token) return res.status(401).json({ error: 'No token provided' });
|
|
|
|
jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret', (err, user) => {
|
|
if (err) return res.status(403).json({ error: 'Invalid token' });
|
|
req.user = user;
|
|
next();
|
|
});
|
|
};
|
|
|
|
// Registration
|
|
app.post('/api/register', authLimiter, async (req, res) => {
|
|
try {
|
|
const { username, email, password } = req.body;
|
|
|
|
if (!username || !email || !password) {
|
|
return res.status(400).json({ error: 'All fields are required' });
|
|
}
|
|
|
|
// Check if user exists
|
|
const userExists = await pool.query('SELECT * FROM users WHERE email = $1 OR username = $2', [email, username]);
|
|
if (userExists.rows.length > 0) {
|
|
return res.status(400).json({ error: 'User already exists' });
|
|
}
|
|
|
|
// Hash password
|
|
const salt = await bcrypt.genSalt(10);
|
|
const passwordHash = await bcrypt.hash(password, salt);
|
|
|
|
// Save to DB
|
|
const newUser = await pool.query(
|
|
'INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email',
|
|
[username, email, passwordHash]
|
|
);
|
|
|
|
res.status(201).json({ message: 'Registration successful', user: newUser.rows[0] });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// Login
|
|
app.post('/api/login', authLimiter, async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
}
|
|
|
|
// Find user
|
|
const user = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
|
if (user.rows.length === 0) {
|
|
return res.status(400).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Check password
|
|
const validPassword = await bcrypt.compare(password, user.rows[0].password_hash);
|
|
if (!validPassword) {
|
|
return res.status(400).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
// Create token
|
|
const token = jwt.sign(
|
|
{ id: user.rows[0].id, username: user.rows[0].username },
|
|
process.env.JWT_SECRET || 'fallback_secret',
|
|
{ expiresIn: '24h' }
|
|
);
|
|
|
|
res.json({ token, user: { id: user.rows[0].id, username: user.rows[0].username, email: user.rows[0].email } });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// Get user profile (protected route)
|
|
app.get('/api/profile', authenticateToken, async (req, res) => {
|
|
try {
|
|
const user = await pool.query('SELECT id, username, email, created_at FROM users WHERE id = $1', [req.user.id]);
|
|
if (user.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
res.json(user.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
|
|
// Verify token
|
|
app.get('/api/verify-token', authenticateToken, (req, res) => {
|
|
res.json({ valid: true, user: req.user });
|
|
});
|
|
app.post('/api/verify-token', authenticateToken, (req, res) => {
|
|
res.json({ valid: true, user: req.user });
|
|
});
|
|
|
|
// Sync data (beans and brew logs)
|
|
app.post('/api/sync', authenticateToken, async (req, res) => {
|
|
const userId = req.user.id;
|
|
const { lastSyncedAt, beans = [], brewLogs = [] } = req.body;
|
|
const serverTime = new Date().toISOString();
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 1. Process incoming beans
|
|
for (const bean of beans) {
|
|
await client.query(`
|
|
INSERT INTO beans (id, user_id, name, roastery, tasting_notes, roast_type, roast_date, image, updated_at, is_deleted)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
roastery = EXCLUDED.roastery,
|
|
tasting_notes = EXCLUDED.tasting_notes,
|
|
roast_type = EXCLUDED.roast_type,
|
|
roast_date = EXCLUDED.roast_date,
|
|
image = EXCLUDED.image,
|
|
updated_at = EXCLUDED.updated_at,
|
|
is_deleted = EXCLUDED.is_deleted
|
|
WHERE (EXCLUDED.updated_at > beans.updated_at OR beans.user_id IS NULL)
|
|
AND (beans.user_id = EXCLUDED.user_id OR beans.user_id IS NULL)
|
|
`, [
|
|
bean.id,
|
|
userId,
|
|
bean.name || '',
|
|
bean.roastery || '',
|
|
bean.tastingNotes || '',
|
|
bean.roastType || '',
|
|
bean.roastDate || '',
|
|
bean.image || '',
|
|
bean.updatedAt ? new Date(bean.updatedAt) : new Date(),
|
|
bean.isDeleted || false
|
|
]);
|
|
}
|
|
|
|
// 2. Process incoming brew logs
|
|
for (const log of brewLogs) {
|
|
const { id, beanId, method, notes, rating, createdAt, updatedAt, isDeleted, ...recipeFields } = log;
|
|
const grind = log.grindSize || log.grind || '';
|
|
const waterTemp = log.waterTemp || '';
|
|
const ratio = log.brewRatio || log.ratio || '';
|
|
const yieldVal = log.yield || '';
|
|
const time = log.brewTime || log.time || '';
|
|
|
|
await client.query(`
|
|
INSERT INTO brew_logs (id, user_id, bean_id, method, grind, water_temp, ratio, yield, time, notes, rating, created_at, updated_at, is_deleted, recipe_data)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
bean_id = EXCLUDED.bean_id,
|
|
method = EXCLUDED.method,
|
|
grind = EXCLUDED.grind,
|
|
water_temp = EXCLUDED.water_temp,
|
|
ratio = EXCLUDED.ratio,
|
|
yield = EXCLUDED.yield,
|
|
time = EXCLUDED.time,
|
|
notes = EXCLUDED.notes,
|
|
rating = EXCLUDED.rating,
|
|
created_at = EXCLUDED.created_at,
|
|
updated_at = EXCLUDED.updated_at,
|
|
is_deleted = EXCLUDED.is_deleted,
|
|
recipe_data = EXCLUDED.recipe_data
|
|
WHERE (EXCLUDED.updated_at > brew_logs.updated_at OR brew_logs.user_id IS NULL)
|
|
AND (brew_logs.user_id = EXCLUDED.user_id OR brew_logs.user_id IS NULL)
|
|
`, [
|
|
id,
|
|
userId,
|
|
beanId,
|
|
method || '',
|
|
grind,
|
|
waterTemp,
|
|
ratio,
|
|
yieldVal,
|
|
time,
|
|
notes || '',
|
|
rating || 0,
|
|
createdAt ? BigInt(createdAt) : BigInt(Date.now()),
|
|
updatedAt ? new Date(updatedAt) : new Date(),
|
|
isDeleted || false,
|
|
JSON.stringify(recipeFields)
|
|
]);
|
|
}
|
|
|
|
// 3. Fetch server changes since lastSyncedAt
|
|
let beansQuery = `SELECT * FROM beans WHERE user_id = $1`;
|
|
let logsQuery = `SELECT * FROM brew_logs WHERE user_id = $1`;
|
|
const params = [userId];
|
|
|
|
if (lastSyncedAt) {
|
|
beansQuery += ` AND updated_at > $2`;
|
|
logsQuery += ` AND updated_at > $2`;
|
|
params.push(new Date(lastSyncedAt));
|
|
}
|
|
|
|
const serverBeans = await client.query(beansQuery, params);
|
|
const serverLogs = await client.query(logsQuery, params);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Map DB fields back to camelCase expected by the frontend
|
|
const mappedBeans = serverBeans.rows.map(b => ({
|
|
id: b.id,
|
|
name: b.name,
|
|
roastery: b.roastery,
|
|
tastingNotes: b.tasting_notes,
|
|
roastType: b.roast_type,
|
|
roastDate: b.roast_date,
|
|
image: b.image,
|
|
updatedAt: b.updated_at.toISOString(),
|
|
isDeleted: b.is_deleted
|
|
}));
|
|
|
|
const mappedLogs = serverLogs.rows.map(l => {
|
|
const baseLog = {
|
|
id: l.id,
|
|
beanId: l.bean_id,
|
|
method: l.method,
|
|
notes: l.notes,
|
|
rating: l.rating,
|
|
createdAt: Number(l.created_at),
|
|
updatedAt: l.updated_at.toISOString(),
|
|
isDeleted: l.is_deleted
|
|
};
|
|
|
|
const recipeData = l.recipe_data || {};
|
|
|
|
// Fallback for old database rows that don't have recipe_data populated
|
|
const fallback = {};
|
|
if (!recipeData.grindSize && l.grind) fallback.grindSize = l.grind;
|
|
if (!recipeData.waterTemp && l.water_temp) fallback.waterTemp = l.water_temp;
|
|
if (!recipeData.brewRatio && l.ratio) fallback.brewRatio = l.ratio;
|
|
if (!recipeData.yield && l.yield) fallback.yield = l.yield;
|
|
if (!recipeData.brewTime && l.time) fallback.brewTime = l.time;
|
|
|
|
return {
|
|
...baseLog,
|
|
...fallback,
|
|
...recipeData
|
|
};
|
|
});
|
|
|
|
res.json({
|
|
serverTime,
|
|
beans: mappedBeans,
|
|
brewLogs: mappedLogs
|
|
});
|
|
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error('Sync failed', err);
|
|
res.status(500).json({ error: 'Sync failed' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on port ${PORT}`);
|
|
});
|