diff --git a/src/api/index.js b/src/api/index.js index 08b77bf..c5313f7 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -12,6 +12,7 @@ import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; import { closeAll as closeAllSse } from './sse.js'; import { getOrCreateCert } from '../util/cert.js'; +import v1Router from '../v1/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -85,6 +86,8 @@ async function createApi() { } await loadRoutes(app, '/api'); + app.use('/v1', v1Router); + app.use('/api/v1', v1Router); app.use((err, _req, res, next) => { if (err.type === 'entity.too.large') { diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index f1df9e0..5656ec2 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -51,6 +51,10 @@ export const authMiddleware = async (req, res, next) => { return next(); } + if (req.path.startsWith('/v1') || req.path.startsWith('/api/v1')) { + return next(); + } + if (req.path.startsWith('/@') || req.path.startsWith('/node_modules') || req.path.startsWith('/src') || req.path === '/favicon.ico') { return next(); } diff --git a/src/db/company.js b/src/db/company.js index ccaf5e5..daf862f 100644 --- a/src/db/company.js +++ b/src/db/company.js @@ -5,6 +5,11 @@ export async function listCompanies(authId) { return store.company.list(authId); } +export async function getCompanyById(id) { + const store = await getStore(); + return store.company.getById(id); +} + export async function createCompany(googleAuthId, data) { const store = await getStore(); return store.company.create({ ...data, googleAuthId }); diff --git a/src/db/posterminal.js b/src/db/posterminal.js index 1e58fb8..3d592ac 100644 --- a/src/db/posterminal.js +++ b/src/db/posterminal.js @@ -34,3 +34,13 @@ export async function setOffAll(googleAuthId, posterminalId) { const store = await getStore(); return store.posterminal.setOffAll(googleAuthId, posterminalId); } + +export async function findByPairingpin(pairingpin) { + const store = await getStore(); + return store.posterminal.findByPairingpin(pairingpin); +} + +export async function pair(id, data) { + const store = await getStore(); + return store.posterminal.pair(id, data); +} diff --git a/src/util/cert.js b/src/util/cert.js index ad51140..7890ac7 100644 --- a/src/util/cert.js +++ b/src/util/cert.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import { X509Certificate } from 'node:crypto'; import selfsigned from 'selfsigned'; import env from '../config/env.js'; @@ -69,3 +70,18 @@ export async function getOrCreateCert() { cert: pems.cert, }; } + +/** + * Derives pairing metadata (fingerprints and serial number) from the TLS certificate. + * @returns {Promise<{ certificateFingerprint: string, certificateSerialNumber: string, serverFingerprint: string }>} + */ +export async function getCertMetadata() { + const { cert } = await getOrCreateCert(); + const x509 = new X509Certificate(cert); + const sha1 = x509.fingerprint; + return { + certificateFingerprint: sha1.replace(/:/g, ''), + certificateSerialNumber: x509.serialNumber, + serverFingerprint: sha1.replace(/:/g, '-'), + }; +} diff --git a/src/v1/index.js b/src/v1/index.js new file mode 100644 index 0000000..3b6bb33 --- /dev/null +++ b/src/v1/index.js @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { handleClient } from './routes/client.js'; + +const router = Router(); + +// POS discovery and pairing +router.get('/client', handleClient); + +export default router; diff --git a/src/v1/routes/client.js b/src/v1/routes/client.js new file mode 100644 index 0000000..266d660 --- /dev/null +++ b/src/v1/routes/client.js @@ -0,0 +1,126 @@ +import crypto from 'node:crypto'; +import * as db from '../../db/posterminal.js'; +import { getCompanyById } from '../../db/company.js'; +import { getCertMetadata } from '../../util/cert.js'; + +export function serverTimestamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function buildClientStep1(config) { + const { + certificateFingerprint, + certificateSerialNumber, + mandantId, + serverFingerprint, + } = config; + + return { + authCode: null, + authToken: null, + certificateFingerprint, + certificateSerialNumber, + mandantId, + mandantName: null, + mandantDatabase: null, + serverFingerprint, + name: null, + serverTimestamp: serverTimestamp(), + }; +} + +export function buildClientStep2(authCode, config) { + const { + authToken, + certificateFingerprint, + certificateSerialNumber, + mandantId, + mandantName, + mandantDatabase, + } = config; + + return { + authCode, + authToken, + certificateFingerprint, + certificateSerialNumber, + mandantId, + mandantName, + mandantDatabase, + serverFingerprint: null, + name: null, + serverTimestamp: serverTimestamp(), + }; +} + +/** + * Handles GET /v1/client + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +export async function handleClient(req, res) { + const authCode = String(req.query.authCode || '').trim(); + const name = String(req.query.name || 'JTL-POS').trim(); + + try { + const certMeta = await getCertMetadata(); + + if (authCode.length <= 4 && authCode.length > 0) { + const config = { + ...certMeta, + mandantId: process.env.MANDANT_ID || '1', + }; + return res.status(200).json(buildClientStep1(config)); + } + + if (authCode.length === 6) { + const terminal = await db.findByPairingpin(authCode); + if (terminal) { + const token = terminal.token; + const strToken = String(token || crypto.randomBytes(16).toString('hex')).trim(); + const pairedAt = serverTimestamp(); + + await db.pair(terminal.id, { + token: strToken, + clientserial: name, + pairedAt, + }); + + let mandantName = process.env.MANDANT_NAME || 'eB-Standard'; + let mandantDatabase = process.env.MANDANT_DATABASE || 'eazybusiness'; + let mandantId = String(terminal.companyId || process.env.MANDANT_ID || '1'); + + if (terminal.companyId) { + try { + const company = await getCompanyById(terminal.companyId); + if (company) { + mandantName = company.name; + mandantDatabase = company.name; + } + } catch { + // ignore lookup error and fallback to defaults + } + } + + const config = { + ...certMeta, + authToken: strToken, + mandantId, + mandantName, + mandantDatabase, + }; + + return res.status(200).json(buildClientStep2(authCode, config)); + } + + return res.status(400).json({ Message: 'Der Authentifizierungscode ist falsch.' }); + } + + return res.status(400).json({ Message: 'Keinen passenden Authentifizierungscode gefunden.' }); + } catch (err) { + console.error('POS /v1/client error:', err); + return res.status(500).json({ Message: 'Internal server error' }); + } +}