Genesis
This commit is contained in:
136
src/api/index.js
Normal file
136
src/api/index.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { loggerMiddleware } from './middleware/logger.js';
|
||||
import env from '../config/env.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { ensureConnection } from '../db/client.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function loadRoutes(app, basePath = '') {
|
||||
const routesDir = path.join(__dirname, 'routes');
|
||||
|
||||
async function walk(dir) {
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else if (file.endsWith('.js')) {
|
||||
const routeModule = await import(pathToFileURL(fullPath).href);
|
||||
if (routeModule.register) {
|
||||
routeModule.register(app, basePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(routesDir);
|
||||
}
|
||||
|
||||
let server;
|
||||
let starting;
|
||||
let startedAt;
|
||||
|
||||
function corsMiddleware(req, res, next) {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (origin && env.corsOrigins.includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
async function createApi() {
|
||||
const app = express();
|
||||
|
||||
app.use(corsMiddleware);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(loggerMiddleware);
|
||||
app.use(authMiddleware);
|
||||
|
||||
if (startedAt) {
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Server-Started-At', startedAt);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
await loadRoutes(app, '/api');
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function startApi() {
|
||||
if (server) {
|
||||
return server;
|
||||
}
|
||||
if (starting) {
|
||||
return starting;
|
||||
}
|
||||
|
||||
starting = (async () => {
|
||||
await ensureConnection();
|
||||
|
||||
startedAt = new Date().toISOString();
|
||||
const app = await createApi();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const instance = app.listen(env.port);
|
||||
|
||||
instance.once('listening', () => {
|
||||
server = instance;
|
||||
console.log(`API listening on port ${env.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
instance.once('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
let detail = '';
|
||||
try {
|
||||
const pids = execSync(`lsof -ti :${env.port}`, { encoding: 'utf8' }).trim();
|
||||
if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`;
|
||||
} catch {
|
||||
// lsof unavailable or no match
|
||||
}
|
||||
console.error(`Port ${env.port} is already in use${detail}. Check with: lsof -i :${env.port}`);
|
||||
} else {
|
||||
console.error('Server error:', err.message);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await starting;
|
||||
} finally {
|
||||
starting = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopApi() {
|
||||
if (!server) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
server = undefined;
|
||||
}
|
||||
85
src/api/middleware/auth.js
Normal file
85
src/api/middleware/auth.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import env from '../../config/env.js';
|
||||
import * as db from '../../db/googleAuth.js';
|
||||
import { meJson } from '../routes/auth/meJson.js';
|
||||
|
||||
const userCache = new Map();
|
||||
const USER_CACHE_TTL = 10 * 60 * 1000;
|
||||
|
||||
export function getCachedUser(id) {
|
||||
const cached = userCache.get(id);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
userCache.delete(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCachedUser(id, user) {
|
||||
userCache.set(id, { user, expiresAt: Date.now() + USER_CACHE_TTL });
|
||||
if (userCache.size > 1000) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of userCache) {
|
||||
if (v.expiresAt <= now) userCache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedUser(id) {
|
||||
userCache.delete(id);
|
||||
}
|
||||
|
||||
function authMeJson(res, req, status, body) {
|
||||
if (req.path === '/api/auth/me') {
|
||||
return meJson(res, status, body);
|
||||
}
|
||||
return res.status(status).json(body);
|
||||
}
|
||||
|
||||
export const authMiddleware = async (req, res, next) => {
|
||||
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/@') || req.path.startsWith('/node_modules') || req.path.startsWith('/src') || req.path === '/favicon.ico') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = req.cookies.token;
|
||||
if (!token) return authMeJson(res, req, 401, { error: 'Unauthorized' });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, env.jwtSecret);
|
||||
|
||||
let user = getCachedUser(decoded.id);
|
||||
if (!user) {
|
||||
user = db.getAllowedEmailById(decoded.id);
|
||||
if (user) setCachedUser(decoded.id, user);
|
||||
}
|
||||
if (!user) {
|
||||
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
...(decoded.picture && { picture: decoded.picture }),
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (decoded.exp - now < 2 * 60 * 60) {
|
||||
const { iat, exp, ...payload } = decoded;
|
||||
const newToken = jwt.sign(payload, env.jwtSecret, { expiresIn: '1d' });
|
||||
res.cookie('token', newToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch {
|
||||
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
19
src/api/middleware/logger.js
Normal file
19
src/api/middleware/logger.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export const loggerMiddleware = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const method = chalk.bold.blue(req.method.padEnd(7));
|
||||
const url = req.originalUrl;
|
||||
let status = res.statusCode;
|
||||
|
||||
if (status >= 500) status = chalk.red(status);
|
||||
else if (status >= 400) status = chalk.yellow(status);
|
||||
else if (status >= 300) status = chalk.cyan(status);
|
||||
else status = chalk.green(status);
|
||||
|
||||
console.log(`${method} ${url} ${status} ${chalk.gray(`(${duration}ms)`)}`);
|
||||
});
|
||||
next();
|
||||
};
|
||||
70
src/api/routes/auth/login.js
Normal file
70
src/api/routes/auth/login.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import env from '../../../config/env.js';
|
||||
import * as db from '../../../db/googleAuth.js';
|
||||
|
||||
const googleClient = new OAuth2Client(env.googleClientId);
|
||||
|
||||
/**
|
||||
* Registers the login route (Google SSO → JWT cookie session).
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route POST /auth/login
|
||||
* @description Verifies a Google ID token, checks allowlist, and sets an HTTP-only `token` cookie.
|
||||
* @body {string} idToken - Google ID token from Google Identity Services
|
||||
* @returns {Object} 200 - `{ message, user }` with `{ id, email, picture? }` in `user`
|
||||
* @returns {Object} 400 - Missing token or Google client not configured
|
||||
* @returns {Object} 401 - Invalid Google token
|
||||
* @returns {Object} 403 - Email not on allowlist
|
||||
* @returns {Object} 500 - Server error
|
||||
*/
|
||||
app.post(`${basePath}/auth/login`, async (req, res) => {
|
||||
try {
|
||||
const { idToken } = req.body;
|
||||
if (!idToken) {
|
||||
return res.status(400).json({ error: 'idToken is required' });
|
||||
}
|
||||
|
||||
if (!env.googleClientId) {
|
||||
return res.status(400).json({ error: 'GOOGLE_CLIENT_ID is not configured' });
|
||||
}
|
||||
|
||||
const ticket = await googleClient.verifyIdToken({
|
||||
idToken,
|
||||
audience: env.googleClientId,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
const email = payload?.email?.toLowerCase();
|
||||
|
||||
if (!email || !payload.email_verified) {
|
||||
return res.status(401).json({ error: 'Invalid Google account' });
|
||||
}
|
||||
|
||||
const allowed = db.getAllowedEmail(email);
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'Email is not authorized' });
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: allowed.id,
|
||||
email: allowed.email,
|
||||
...(payload.picture && { picture: payload.picture }),
|
||||
};
|
||||
const token = jwt.sign(user, env.jwtSecret, { expiresIn: '1d' });
|
||||
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
res.status(200).json({ message: 'Login successful', user });
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid Google token' });
|
||||
}
|
||||
});
|
||||
}
|
||||
36
src/api/routes/auth/logout.js
Normal file
36
src/api/routes/auth/logout.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import env from '../../../config/env.js';
|
||||
import { clearCachedUser } from '../../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Registers the logout route (clears session cookie).
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route POST /auth/logout
|
||||
* @description Clears the HTTP-only `token` cookie set at login.
|
||||
* @returns {Object} 200 - `{ message }` confirming logout
|
||||
*/
|
||||
app.post(`${basePath}/auth/logout`, (req, res) => {
|
||||
const token = req.cookies.token;
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true });
|
||||
if (decoded?.id) {
|
||||
clearCachedUser(decoded.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cache clearing on logout
|
||||
}
|
||||
}
|
||||
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
res.status(200).json({ message: 'Logged out successfully' });
|
||||
});
|
||||
}
|
||||
19
src/api/routes/auth/me.js
Normal file
19
src/api/routes/auth/me.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { meJson } from './meJson.js';
|
||||
|
||||
/**
|
||||
* Registers the current-user route (requires prior auth middleware populating `req.user`).
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /auth/me
|
||||
* @description Returns the authenticated user from `req.user` and the Google client ID.
|
||||
* @returns {Object} 200 - `{ user: { id, email, picture? }, googleClientId }`
|
||||
*/
|
||||
app.get(`${basePath}/auth/me`, (req, res) => {
|
||||
const { id, email, picture } = req.user;
|
||||
const user = { id, email, ...(picture && { picture }) };
|
||||
meJson(res, 200, { user });
|
||||
});
|
||||
}
|
||||
5
src/api/routes/auth/meJson.js
Normal file
5
src/api/routes/auth/meJson.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import env from '../../../config/env.js';
|
||||
|
||||
export function meJson(res, status, body) {
|
||||
return res.status(status).json({ ...body, googleClientId: env.googleClientId });
|
||||
}
|
||||
23
src/api/routes/health.js
Normal file
23
src/api/routes/health.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ensureConnection } from '../../db/client.js';
|
||||
|
||||
/**
|
||||
* Registers a database connectivity health check route.
|
||||
* @param {import('express').Application} app - Express app
|
||||
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /health
|
||||
* @description Verifies the database client can establish a connection.
|
||||
* @returns {Object} 200 - `{ ok: true }` when the database is reachable
|
||||
* @returns {Object} 503 - `{ ok: false, error }` when the check fails
|
||||
*/
|
||||
app.get(`${basePath}/health`, async (_request, response) => {
|
||||
try {
|
||||
await ensureConnection();
|
||||
response.status(200).json({ ok: true });
|
||||
} catch (error) {
|
||||
response.status(503).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user