Files
BrewJournal/server/index.js

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}`);
});