start on migrating from Supabase to local Postgress, and passport.js for auth because CORS

This commit is contained in:
2025-11-19 12:42:34 -08:00
parent 11dbc91843
commit 593c33c977
25 changed files with 3508 additions and 1427 deletions

354
server.ts Normal file
View File

@@ -0,0 +1,354 @@
import express, { Request, Response, NextFunction } from 'express';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
import fs from 'fs/promises';
import { findUserByEmail, createUser, findUserById, findUserProfileById, updateUserPreferences, updateUserPassword, deleteUserById, checkTablesExist, getPoolStatus, saveRefreshToken, findUserByRefreshToken } from './src/services/db';
import { logger } from './src/services/logger';
// Load environment variables from a .env file at the root of your project
dotenv.config();
const app = express();
app.use(express.json()); // Middleware to parse JSON request bodies
app.use(cookieParser()); // Middleware to parse cookies
app.use(passport.initialize()); // Initialize Passport
// --- Configuration ---
// IMPORTANT: Use a strong, randomly generated secret key and store it securely
// in your .env file, not hardcoded here.
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this';
if (JWT_SECRET === 'your_super_secret_jwt_key_change_this') {
logger.warn('Security Warning: JWT_SECRET is using a default, insecure value. Please set a strong secret in your .env file.');
}
// --- Passport Local Strategy (for email/password login) ---
passport.use(new LocalStrategy(
{ usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username'
async (email, password, done) => {
try {
// 1. Find the user in your PostgreSQL database by email.
const user = await findUserByEmail(email);
if (!user) {
// User not found
logger.warn(`Login attempt failed for non-existent user: ${email}`);
return done(null, false, { message: 'Incorrect email or password.' });
}
// 2. Compare the submitted password with the hashed password in your DB.
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) {
// Password does not match
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
return done(null, false, { message: 'Incorrect email or password.' });
}
// 3. Success! Return the user object (without password_hash for security).
const { password_hash, ...userWithoutHash } = user;
logger.info(`User successfully authenticated: ${email}`);
return done(null, userWithoutHash);
} catch (err) {
logger.error('Error during local authentication strategy:', { error: err });
return done(err);
}
}
));
// --- Passport JWT Strategy (for protecting API routes) ---
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer <token>' header
secretOrKey: JWT_SECRET,
};
passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
try {
// The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }).
// We re-fetch the user from the database here to ensure they are still active and valid.
const user = await findUserById(jwt_payload.id);
if (user) {
return done(null, user); // User object will be available as req.user in protected routes
} else {
logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`);
return done(null, false); // User not found or invalid token
}
} catch (err) {
logger.error('Error during JWT authentication strategy:', { error: err });
return done(err, false);
}
}));
// --- API Routes ---
// --- Health & System Check Routes ---
app.get('/api/health/ping', (req: Request, res: Response) => {
res.status(200).send('pong');
});
app.get('/api/health/db-schema', async (req: Request, res: Response) => {
try {
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
const missingTables = await checkTablesExist(requiredTables);
if (missingTables.length > 0) {
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
} catch (error) {
logger.error('Error during DB schema check:', { error });
return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' });
}
});
app.get('/api/health/storage', async (req: Request, res: Response) => {
// This path should be an absolute path on your server, configured via an environment variable.
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
try {
await fs.access(storagePath, fs.constants.W_OK); // Check for write access
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
} catch (error) {
logger.error(`Storage check failed for path: ${storagePath}`, { error });
return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` });
}
});
app.get('/api/health/db-pool', (req: Request, res: Response) => {
try {
const status = getPoolStatus();
// A healthy pool should ideally have 0 waiting clients. A small number might be acceptable under load.
const isHealthy = status.waitingCount < 5; // Arbitrary threshold for "healthy"
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
if (isHealthy) {
return res.status(200).json({ success: true, message });
} else {
logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` });
}
} catch (error) {
logger.error('Error during DB pool health check:', { error });
return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' });
}
});
// --- Authentication Routes ---
// Registration Route
app.post('/api/auth/register', async (req: Request, res: Response, next: NextFunction) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required.' });
}
try {
const existingUser = await findUserByEmail(email);
if (existingUser) {
logger.warn(`Registration attempt for existing email: ${email}`);
return res.status(409).json({ message: 'User with that email already exists.' });
}
// Hash the password before storing it
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const newUser = await createUser(email, hashedPassword);
logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`);
// Immediately log in the user by issuing a JWT
const payload = { id: newUser.id, email: newUser.email };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token expires in 1 hour
// Generate and save a refresh token
const refreshToken = crypto.randomBytes(64).toString('hex');
await saveRefreshToken(newUser.id, refreshToken);
// Send the refresh token in a secure, HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // The cookie is not accessible via client-side script
secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.status(201).json({ message: 'User registered successfully!', user: payload, token });
} catch (err) {
logger.error('Error during /register route handling:', { error: err });
return next(err); // Pass error to Express error handler
}
});
// Login Route
app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => {
// Use passport.authenticate with the 'local' strategy
// { session: false } because we're using JWTs, not server-side sessions
passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => {
if (err) {
logger.error('Login authentication error in /login route:', { error: err });
return next(err); // Pass server errors to the error handler
}
if (!user) {
// Authentication failed (e.g., incorrect credentials)
return res.status(401).json({ message: info ? info.message : 'Login failed' });
}
// User is authenticated, create and sign a JWT
// The user object here is what was returned from the LocalStrategy's `done` callback
const typedUser = user as { id: string; email: string };
const payload = { id: typedUser.id, email: typedUser.email };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // Short-lived access token
// Generate and save a refresh token
const refreshToken = crypto.randomBytes(64).toString('hex');
saveRefreshToken(typedUser.id, refreshToken).then(() => {
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
// Send the refresh token in a secure, HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.json({ user: payload, token: accessToken });
}).catch(tokenErr => {
logger.error('Failed to save refresh token during login:', { error: tokenErr });
return next(tokenErr);
});
})(req, res, next); // This is crucial for Passport middleware to work correctly
});
// New Route to refresh the access token
app.post('/api/auth/refresh-token', async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
}
const user = await findUserByRefreshToken(refreshToken);
if (!user) {
return res.status(403).json({ message: 'Invalid refresh token.' });
}
const payload = { id: user.id, email: user.email };
const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
res.json({ token: newAccessToken });
});
// Example Protected Route to get user profile
// The frontend can call this route on startup to validate a stored JWT
app.get('/api/users/profile', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
// If JWT authentication is successful, req.user will contain the user object
// from the JwtStrategy's `done` callback.
const authenticatedUser = req.user as { id: string; email: string };
logger.info(`Profile requested for user: ${authenticatedUser.email}`);
try {
const profile = await findUserProfileById(authenticatedUser.id);
if (!profile) {
logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`);
return res.status(404).json({ message: 'Profile not found for this user.' });
}
res.json(profile); // Return the full profile object
} catch (error) {
logger.error('Error fetching profile in /api/users/profile:', { error });
res.status(500).json({ message: 'Failed to retrieve user profile.' });
}
});
// Protected Route to update user preferences
app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
const authenticatedUser = req.user as { id: string; email: string };
const newPreferences = req.body;
if (!newPreferences || typeof newPreferences !== 'object') {
return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' });
}
logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences });
try {
const updatedProfile = await updateUserPreferences(authenticatedUser.id, newPreferences);
res.json(updatedProfile);
} catch (error) {
logger.error('Error updating preferences in /api/users/profile/preferences:', { error });
res.status(500).json({ message: 'Failed to update user preferences.' });
}
});
// Protected Route to update user password
app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
const authenticatedUser = req.user as { id: string; email: string };
const { newPassword } = req.body;
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' });
}
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
logger.info(`Hashing new password for user: ${authenticatedUser.email}`);
await updateUserPassword(authenticatedUser.id, hashedPassword);
logger.info(`Successfully updated password for user: ${authenticatedUser.email}`);
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
logger.error('Error during password update:', { error });
res.status(500).json({ message: 'Failed to update password.' });
}
});
// Protected Route to delete a user account
app.delete('/api/users/account', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => {
const authenticatedUser = req.user as { id: string; email: string };
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: 'Password is required for account deletion.' });
}
try {
// 1. Fetch the user from DB to get their current password hash for verification
const userWithHash = await findUserByEmail(authenticatedUser.email);
if (!userWithHash) {
return res.status(404).json({ message: 'User not found.' });
}
// 2. Compare the submitted password with the stored hash
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
if (!isMatch) {
logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`);
return res.status(403).json({ message: 'Incorrect password.' });
}
// 3. If password matches, delete the user. The `ON DELETE CASCADE` in your schema will clean up related data.
await deleteUserById(authenticatedUser.id);
logger.warn(`User account deleted successfully: ${authenticatedUser.email}`);
res.status(200).json({ message: 'Account deleted successfully.' });
} catch (error) {
logger.error('Error during account deletion:', { error });
res.status(500).json({ message: 'Failed to delete account.' });
}
});
// --- Error Handling and Server Startup ---
// Basic error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error('Unhandled application error:', { error: err.stack });
res.status(500).send('Something broke!');
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
logger.info(`Authentication server started on port ${PORT}`);
});