This commit is contained in:
seb
2026-10-01 22:48:44 +02:00
parent ea51f30a8d
commit 20886e7c76
9 changed files with 16471 additions and 62 deletions

View File

@@ -47,7 +47,7 @@ function authMeJson(res, req, status, 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') {
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health' || req.path === '/api/openapi.json') {
return next();
}
@@ -59,14 +59,23 @@ export const authMiddleware = async (req, res, next) => {
return next();
}
const token = req.cookies.token;
if (!token) return authMeJson(res, req, 401, { error: 'Unauthorized' });
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7).trim()
: null;
const token = req.cookies.token || bearerToken;
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
if (!token) {
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Unauthorized' });
}
try {
const decoded = jwt.verify(token, env.jwtSecret);
const authId = Number(decoded.id);
if (!Number.isInteger(authId) || authId <= 0) {
clearTokenCookie(res);
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Invalid token' });
}
@@ -82,7 +91,8 @@ export const authMiddleware = async (req, res, next) => {
}
if (!user) {
clearCachedUser(authId);
clearTokenCookie(res);
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
}
@@ -93,7 +103,7 @@ export const authMiddleware = async (req, res, next) => {
};
const now = Math.floor(Date.now() / 1000);
if (decoded.exp - now < 2 * 60 * 60) {
if (req.cookies.token && decoded.exp - now < 2 * 60 * 60) {
const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
res.cookie('token', newToken, {
...TOKEN_COOKIE,
@@ -103,7 +113,9 @@ export const authMiddleware = async (req, res, next) => {
next();
} catch {
clearTokenCookie(res);
if (req.cookies.token) clearTokenCookie(res);
if (isDocs) return next();
return authMeJson(res, req, 401, { error: 'Invalid token' });
}
};

View File

@@ -10,6 +10,11 @@
* in: cookie
* name: token
* description: HTTP-only session cookie set by POST /auth/login
* bearerAuth:
* type: http
* scheme: bearer
* bearerFormat: JWT
* description: JWT token in Authorization header (Bearer <token>)
* schemas:
* Error:
* type: object

69
src/api/openapiSpec.js Normal file
View File

@@ -0,0 +1,69 @@
import path from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '../..');
export const openApiOptions = {
definition: {
openapi: '3.0.3',
info: {
title: 'Dash API',
version: '1.0.0',
description:
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
'Browser clients must send cookies (`credentials: \'include\'`) or an `Authorization: Bearer <token>` header. ' +
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
},
servers: [
{ url: '/api', description: 'API Base (/api)' },
{ url: '/', description: 'Root (/)' },
],
tags: [
{ name: 'Auth', description: 'Google SSO and session' },
{ name: 'Health', description: 'Connectivity checks' },
{ name: 'POS', description: 'POS bind and sync API (/v1)' },
{ name: 'Events', description: 'SSE update signals for live clients' },
{ name: 'Article', description: 'Articles under categories' },
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
{ name: 'CompanySetting', description: 'Company-scoped settings' },
{ name: 'Unit', description: 'Company-scoped units of measure' },
{ name: 'Brand', description: 'Company-scoped brands' },
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
{ name: 'Ledger', description: 'Append-only ledger transactions' },
{ name: 'Stock', description: 'Manual stock additions and removals' },
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
],
},
apis: [
path.join(root, 'src/api/openapi.js'),
path.join(root, 'src/api/routes/**/*.js'),
path.join(root, 'src/v1/**/*.js'),
],
};
let cachedSpec = null;
/**
* Returns the parsed OpenAPI 3.0 specification object, cached in memory.
* @param {boolean} [refresh=false] - Force rebuild of the spec
* @returns {object}
*/
export function getOpenApiSpec(refresh = false) {
if (!cachedSpec || refresh) {
cachedSpec = swaggerJsdoc(openApiOptions);
}
return cachedSpec;
}

111
src/api/routes/docs.js Normal file
View File

@@ -0,0 +1,111 @@
import express from 'express';
import swaggerUiDist from 'swagger-ui-dist';
import { getOpenApiSpec } from '../openapiSpec.js';
const swaggerDistPath = swaggerUiDist.getAbsoluteFSPath();
function renderDocsHtml() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dash API</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: './openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: 'StandaloneLayout',
requestInterceptor: (req) => {
req.credentials = 'include';
return req;
}
});
};
</script>
</body>
</html>`;
}
/**
* Registers documentation and interactive API tester under /docs/.
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Base path (e.g. '/api')
*/
export function register(app, basePath = '') {
const docsRouter = express.Router();
// Redirect /docs (or /api/docs) to /docs/ so relative assets resolve properly
docsRouter.use((req, res, next) => {
const rawPath = req.originalUrl.split('?')[0];
if (rawPath === '/docs' || rawPath === '/api/docs' || rawPath === '/api/docs/') {
const query = req.originalUrl.includes('?') ? '?' + req.originalUrl.split('?')[1] : '';
return res.redirect(301, '/docs/' + query);
}
next();
});
// Serve OpenAPI 3.0 specification JSON
docsRouter.get('/openapi.json', (_req, res) => {
try {
const spec = getOpenApiSpec();
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(JSON.stringify(spec, null, 2));
} catch (err) {
console.error('Failed to generate OpenAPI spec:', err);
res.status(500).json({ error: 'Failed to generate OpenAPI specification' });
}
});
// Serve HTML UI for /docs/ and /docs/index.html
docsRouter.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderDocsHtml());
});
docsRouter.get('/index.html', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderDocsHtml());
});
// Serve static assets from swagger-ui-dist (CSS, JS bundles, icons)
docsRouter.use(express.static(swaggerDistPath, { index: false }));
// Mount on /docs
app.use('/docs', docsRouter);
// Also mount on /api/docs and alias /api/openapi.json if basePath is set
if (basePath) {
app.use(basePath + '/docs', docsRouter);
app.get(basePath + '/openapi.json', (_req, res) => res.redirect(301, '/docs/openapi.json'));
}
}