Genesis
This commit is contained in:
5
.cursor/rules/dont-start-the-API-server.mdc
Normal file
5
.cursor/rules/dont-start-the-API-server.mdc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
the user will do it, just let syntax check and you may run it with a differend port and self exiting, but never let anything run listeing on ports the user may want to use to start it also. next: the user keeps his own dev server running all the time, don't interfer.
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Server
|
||||||
|
PORT=4208
|
||||||
|
|
||||||
|
# CORS — comma-separated allowed origins
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# Auth — Google OAuth client ID and JWT signing secret
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
JWT_SECRET=fallback_secret_please_change_in_production
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLITE_PATH=data/dashback.db
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Dashback API
|
||||||
|
|
||||||
|
Backend API for the Dashback frontend. Default base URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:4208/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## CORS & cookies
|
||||||
|
|
||||||
|
All browser requests that need auth must send cookies:
|
||||||
|
|
||||||
|
```js
|
||||||
|
fetch('http://localhost:4208/api/auth/me', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The browser must talk to the API over **HTTPS** for login to work — `Secure` cookies are not sent on plain HTTP.
|
||||||
|
|
||||||
|
## Auth flow
|
||||||
|
|
||||||
|
1. Call `GET /auth/me` to get `googleClientId` (included in 200 and 401 responses).
|
||||||
|
2. Sign in with Google on the frontend (Google Identity Services / One Tap).
|
||||||
|
3. POST the Google ID token to the API.
|
||||||
|
4. The API sets the session cookie.
|
||||||
|
5. Call protected routes with `credentials: 'include'`. The cookie is refreshed automatically when it has less than 2 hours left.
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
|
||||||
|
Public. No cookie required.
|
||||||
|
|
||||||
|
**Body (JSON)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "idToken": "<google-id-token>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Login successful",
|
||||||
|
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
|
||||||
|
| Status | Body |
|
||||||
|
|--------|------|
|
||||||
|
| 400 | `{ "error": "idToken is required" }` |
|
||||||
|
| 401 | `{ "error": "Invalid Google token" }` or `{ "error": "Invalid Google account" }` |
|
||||||
|
| 403 | `{ "error": "Email is not authorized" }` |
|
||||||
|
|
||||||
|
### `POST /auth/logout`
|
||||||
|
|
||||||
|
Public. Clears the session cookie.
|
||||||
|
|
||||||
|
**200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "Logged out successfully" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /auth/me`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie. Every response includes `googleClientId`.
|
||||||
|
|
||||||
|
**200**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." },
|
||||||
|
"googleClientId": "your-client-id.apps.googleusercontent.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
|
||||||
|
| Status | Body |
|
||||||
|
|--------|------|
|
||||||
|
| 401 | `{ "error": "Unauthorized", "googleClientId": "..." }` or `{ "error": "Invalid token", "googleClientId": "..." }` |
|
||||||
|
| 403 | `{ "error": "User is no longer authorized", "googleClientId": "..." }` |
|
||||||
|
|
||||||
|
## Frontend checklist
|
||||||
|
|
||||||
|
- [ ] `credentials: 'include'` on every API call
|
||||||
|
- [ ] HTTPS in dev (or proxy) so `Secure` cookies work
|
||||||
3491
package-lock.json
generated
Normal file
3491
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "dashback",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"manage-users": "node scripts/manageUsers.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"chalk": "^5.6.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"crypto": "^1.0.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"google-auth-library": "^10.7.0",
|
||||||
|
"ink": "^7.1.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mssql": "^12.5.5",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"sharp": "^0.35.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
154
scripts/manageUsers.js
Normal file
154
scripts/manageUsers.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { render, Box, Text, useApp, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import * as googleAuth from '../src/db/googleAuth.js';
|
||||||
|
|
||||||
|
import '../src/config/env.js';
|
||||||
|
|
||||||
|
const e = React.createElement;
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
MENU: 'menu',
|
||||||
|
ADD: 'add',
|
||||||
|
REMOVE: 'remove',
|
||||||
|
LIST: 'list',
|
||||||
|
DONE: 'done',
|
||||||
|
ERROR: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Menu = ({ onSelect }) => {
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const options = [
|
||||||
|
{ label: 'List allowed emails', value: 'list' },
|
||||||
|
{ label: 'Add email', value: 'add' },
|
||||||
|
{ label: 'Remove email', value: 'remove' },
|
||||||
|
{ label: 'Quit', value: 'quit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (key.upArrow) setSelected((s) => Math.max(0, s - 1));
|
||||||
|
if (key.downArrow) setSelected((s) => Math.min(options.length - 1, s + 1));
|
||||||
|
if (key.return) onSelect(options[selected].value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return e(Box, { flexDirection: 'column' },
|
||||||
|
options.map((opt, i) =>
|
||||||
|
e(Text, { key: opt.value },
|
||||||
|
i === selected ? chalk.cyan('> ') : ' ',
|
||||||
|
i === selected ? chalk.cyan.bold(opt.label) : opt.label
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const [mode, setMode] = useState(MODES.MENU);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const finish = (success, text) => {
|
||||||
|
if (success) {
|
||||||
|
setMessage(text);
|
||||||
|
setMode(MODES.DONE);
|
||||||
|
} else {
|
||||||
|
setError(text);
|
||||||
|
setMode(MODES.ERROR);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
exit();
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenu = (action) => {
|
||||||
|
if (action === 'quit') {
|
||||||
|
exit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
setEmail('');
|
||||||
|
setMode(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
try {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
if (!trimmed || !trimmed.includes('@')) {
|
||||||
|
finish(false, 'Invalid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
googleAuth.addAllowedEmail(trimmed);
|
||||||
|
finish(true, `Added ${trimmed.toLowerCase()} to google_auth`);
|
||||||
|
} catch (err) {
|
||||||
|
finish(false, err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
try {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
finish(false, 'Email is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = googleAuth.removeAllowedEmail(trimmed);
|
||||||
|
finish(true, removed ? `Removed ${trimmed.toLowerCase()}` : `Email not found: ${trimmed}`);
|
||||||
|
} catch (err) {
|
||||||
|
finish(false, err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === MODES.DONE) {
|
||||||
|
return e(Text, null, chalk.green(message));
|
||||||
|
}
|
||||||
|
if (mode === MODES.ERROR) {
|
||||||
|
return e(Text, null, chalk.red(`Error: ${error}`));
|
||||||
|
}
|
||||||
|
if (mode === MODES.LIST) {
|
||||||
|
const emails = googleAuth.listAllowedEmails();
|
||||||
|
if (emails.length === 0) {
|
||||||
|
finish(true, 'No allowed emails in google_auth');
|
||||||
|
return e(Text, null, chalk.yellow('No allowed emails...'));
|
||||||
|
}
|
||||||
|
return e(Box, { flexDirection: 'column' },
|
||||||
|
e(Text, null, chalk.cyan.bold('Allowed emails:')),
|
||||||
|
...emails.map((row) => e(Text, { key: row.id }, ` ${row.email}`)),
|
||||||
|
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to exit'))),
|
||||||
|
e(ExitOnKey, { onExit: () => { exit(); process.exit(0); } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === MODES.ADD) {
|
||||||
|
return e(Box, { flexDirection: 'column' },
|
||||||
|
e(Text, null, chalk.cyan.bold('Add allowed email')),
|
||||||
|
e(Box, null,
|
||||||
|
e(Text, null, 'Email: '),
|
||||||
|
e(TextInput, { value: email, onChange: setEmail, onSubmit: handleAdd })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === MODES.REMOVE) {
|
||||||
|
return e(Box, { flexDirection: 'column' },
|
||||||
|
e(Text, null, chalk.cyan.bold('Remove allowed email')),
|
||||||
|
e(Box, null,
|
||||||
|
e(Text, null, 'Email: '),
|
||||||
|
e(TextInput, { value: email, onChange: setEmail, onSubmit: handleRemove })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return e(Box, { flexDirection: 'column' },
|
||||||
|
e(Box, { marginBottom: 1 },
|
||||||
|
e(Text, null, chalk.cyan.bold('=== Google Auth Email Manager ==='))
|
||||||
|
),
|
||||||
|
e(Menu, { onSelect: handleMenu })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExitOnKey = ({ onExit }) => {
|
||||||
|
useInput(onExit);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(e(App));
|
||||||
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
17
src/config/env.js
Normal file
17
src/config/env.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
dotenv.config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port: Number(process.env.PORT) || 4208,
|
||||||
|
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
|
||||||
|
sqlitePath: process.env.SQLITE_PATH,
|
||||||
|
};
|
||||||
46
src/db/client.js
Normal file
46
src/db/client.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import env from '../config/env.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const defaultPath = resolve(__dirname, '../../data/dashback.db');
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
function getDbPath() {
|
||||||
|
return env.sqlitePath || defaultPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema(database) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS google_auth (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!db) {
|
||||||
|
const dbPath = getDbPath();
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
initSchema(db);
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureConnection() {
|
||||||
|
getDb().prepare('SELECT 1').get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDb() {
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
db = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/db/googleAuth.js
Normal file
34
src/db/googleAuth.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getDb } from './client.js';
|
||||||
|
|
||||||
|
export function listAllowedEmails() {
|
||||||
|
return getDb()
|
||||||
|
.prepare('SELECT id, email, created_at FROM google_auth ORDER BY email COLLATE NOCASE')
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedEmail(email) {
|
||||||
|
return getDb()
|
||||||
|
.prepare('SELECT id, email, created_at FROM google_auth WHERE email = ? COLLATE NOCASE')
|
||||||
|
.get(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedEmailById(id) {
|
||||||
|
return getDb()
|
||||||
|
.prepare('SELECT id, email, created_at FROM google_auth WHERE id = ?')
|
||||||
|
.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAllowedEmail(email) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
const result = getDb()
|
||||||
|
.prepare('INSERT INTO google_auth (email) VALUES (?)')
|
||||||
|
.run(normalized);
|
||||||
|
return getAllowedEmailById(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAllowedEmail(email) {
|
||||||
|
const normalized = email.trim().toLowerCase();
|
||||||
|
return getDb()
|
||||||
|
.prepare('DELETE FROM google_auth WHERE email = ? COLLATE NOCASE')
|
||||||
|
.run(normalized).changes > 0;
|
||||||
|
}
|
||||||
25
src/index.js
Normal file
25
src/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import './config/env.js';
|
||||||
|
import { startApi, stopApi } from './api/index.js';
|
||||||
|
import { closeDb } from './db/client.js';
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
await stopApi();
|
||||||
|
try {
|
||||||
|
closeDb();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to close database:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
shutdown().then(() => process.exit(0));
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
shutdown().then(() => process.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
startApi().catch((err) => {
|
||||||
|
console.error('Failed to start API:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user