From 4beafdf719a727c8d283a33a698d1176c874530b Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Wed, 11 Feb 2026 23:22:11 +0100 Subject: [PATCH] Umstellung auf sqlite --- .gitignore | 3 + db.js | 358 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + server.js | 229 +++----------------------------- 4 files changed, 379 insertions(+), 212 deletions(-) create mode 100644 db.js diff --git a/.gitignore b/.gitignore index 8fdbd2c..e42f469 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .env data/codes.json data/pending-orders.json +data/*.db +data/*.db-wal +data/*.db-shm node_modules/ package-lock.json .DS_Store diff --git a/db.js b/db.js new file mode 100644 index 0000000..7046426 --- /dev/null +++ b/db.js @@ -0,0 +1,358 @@ +/** + * SQLite database layer for UniFi Portal (replaces JSON files). + * DB file: data/portal.db + */ + +const path = require('path'); +const fs = require('fs'); + +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'portal.db'); + +let db = null; + +function getDb() { + if (!db) { + const Database = require('better-sqlite3'); + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + initSchema(db); + migrateFromJson(db); + } + return db; +} + +function initSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS codes ( + code TEXT PRIMARY KEY, + createdAt TEXT NOT NULL, + expiresAt TEXT, + useCount INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS packages ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + durationMinutes INTEGER NOT NULL, + price REAL NOT NULL, + currency TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + sortOrder INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS pending_orders ( + orderId TEXT PRIMARY KEY, + guestMac TEXT NOT NULL, + packageId TEXT NOT NULL, + createdAt TEXT NOT NULL + ); + `); +} + +function migrateFromJson(database) { + const codesPath = path.join(DATA_DIR, 'codes.json'); + const packagesPath = path.join(DATA_DIR, 'packages.json'); + const ordersPath = path.join(DATA_DIR, 'pending-orders.json'); + + const hasCodes = database.prepare('SELECT 1 FROM codes LIMIT 1').get(); + if (!hasCodes && fs.existsSync(codesPath)) { + try { + const codes = JSON.parse(fs.readFileSync(codesPath, 'utf8')); + const insert = database.prepare( + 'INSERT OR IGNORE INTO codes (code, createdAt, expiresAt, useCount) VALUES (?, ?, ?, ?)' + ); + for (const c of codes) { + insert.run( + c.code, + c.createdAt || new Date().toISOString(), + c.expiresAt || null, + c.useCount ?? 0 + ); + } + console.log(`Migrated ${codes.length} codes from JSON to SQLite`); + } catch (err) { + console.error('Migration codes.json:', err.message); + } + } + + const hasPackages = database.prepare('SELECT 1 FROM packages LIMIT 1').get(); + if (!hasPackages && fs.existsSync(packagesPath)) { + try { + const packages = JSON.parse(fs.readFileSync(packagesPath, 'utf8')); + const insert = database.prepare( + 'INSERT OR IGNORE INTO packages (id, name, durationMinutes, price, currency, active, sortOrder) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + for (const p of packages) { + insert.run( + p.id, + p.name, + p.durationMinutes ?? 0, + p.price ?? 0, + p.currency || 'EUR', + p.active !== false ? 1 : 0, + p.sortOrder ?? 0 + ); + } + console.log(`Migrated ${packages.length} packages from JSON to SQLite`); + } catch (err) { + console.error('Migration packages.json:', err.message); + } + } + + const hasOrders = database.prepare('SELECT 1 FROM pending_orders LIMIT 1').get(); + if (!hasOrders && fs.existsSync(ordersPath)) { + try { + const orders = JSON.parse(fs.readFileSync(ordersPath, 'utf8')); + const insert = database.prepare( + 'INSERT OR IGNORE INTO pending_orders (orderId, guestMac, packageId, createdAt) VALUES (?, ?, ?, ?)' + ); + for (const [orderId, o] of Object.entries(orders)) { + insert.run(orderId, o.guestMac, o.packageId, o.createdAt || new Date().toISOString()); + } + const count = Object.keys(orders).length; + if (count) console.log(`Migrated ${count} pending orders from JSON to SQLite`); + } catch (err) { + console.error('Migration pending-orders.json:', err.message); + } + } +} + +// --- Codes --- + +function loadCodes() { + const rows = getDb().prepare('SELECT code, createdAt, expiresAt, useCount FROM codes').all(); + return rows.map((r) => ({ + code: r.code, + createdAt: r.createdAt, + expiresAt: r.expiresAt, + useCount: r.useCount ?? 0 + })); +} + +function saveCodes(codes) { + const database = getDb(); + database.prepare('DELETE FROM codes').run(); + const insert = database.prepare( + 'INSERT INTO codes (code, createdAt, expiresAt, useCount) VALUES (?, ?, ?, ?)' + ); + for (const c of codes) { + insert.run(c.code, c.createdAt, c.expiresAt ?? null, c.useCount ?? 0); + } +} + +function findAndValidateCode(codeInput) { + const normalized = String(codeInput || '').trim().toUpperCase(); + if (!normalized) return null; + const row = getDb() + .prepare('SELECT code, createdAt, expiresAt, useCount FROM codes WHERE code = ?') + .get(normalized); + if (!row) return null; + if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null; + return { + code: row.code, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + useCount: row.useCount ?? 0 + }; +} + +function incrementCodeUseCount(codeInput) { + const normalized = String(codeInput || '').trim().toUpperCase(); + getDb().prepare('UPDATE codes SET useCount = useCount + 1 WHERE code = ?').run(normalized); +} + +const CODE_CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; + +function generateCode() { + let code = ''; + for (let i = 0; i < 8; i++) { + code += CODE_CHARS.charAt(Math.floor(Math.random() * CODE_CHARS.length)); + } + return code; +} + +function addCode(expiresInMinutes) { + const code = generateCode(); + const now = new Date().toISOString(); + const expiresAt = expiresInMinutes + ? new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString() + : null; + getDb() + .prepare('INSERT INTO codes (code, createdAt, expiresAt, useCount) VALUES (?, ?, ?, 0)') + .run(code, now, expiresAt); + return { code, createdAt: now, expiresAt, useCount: 0 }; +} + +function deleteCode(codeInput) { + const normalized = String(codeInput || '').trim().toUpperCase(); + const result = getDb().prepare('DELETE FROM codes WHERE code = ?').run(normalized); + return result.changes > 0; +} + +// --- Packages --- + +function loadPackages() { + const rows = getDb() + .prepare( + 'SELECT id, name, durationMinutes, price, currency, active, sortOrder FROM packages' + ) + .all(); + return rows.map((r) => ({ + id: r.id, + name: r.name, + durationMinutes: r.durationMinutes, + price: r.price, + currency: r.currency, + active: Boolean(r.active), + sortOrder: r.sortOrder ?? 0 + })); +} + +function savePackages(packages) { + const database = getDb(); + database.prepare('DELETE FROM packages').run(); + const insert = database.prepare( + 'INSERT INTO packages (id, name, durationMinutes, price, currency, active, sortOrder) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + for (const p of packages) { + insert.run( + p.id, + p.name, + p.durationMinutes, + p.price, + p.currency || 'EUR', + p.active !== false ? 1 : 0, + p.sortOrder ?? 0 + ); + } +} + +function findPackageById(id) { + const row = getDb() + .prepare( + 'SELECT id, name, durationMinutes, price, currency, active, sortOrder FROM packages WHERE id = ?' + ) + .get(id); + if (!row) return null; + return { + id: row.id, + name: row.name, + durationMinutes: row.durationMinutes, + price: row.price, + currency: row.currency, + active: Boolean(row.active), + sortOrder: row.sortOrder ?? 0 + }; +} + +function addPackage({ name, durationMinutes, price, currency, active = true }) { + const crypto = require('crypto'); + const id = crypto.randomUUID(); + const database = getDb(); + const maxOrder = database.prepare('SELECT COALESCE(MAX(sortOrder), 0) AS m FROM packages').get(); + const sortOrder = (maxOrder?.m ?? 0) + 1; + database + .prepare( + 'INSERT INTO packages (id, name, durationMinutes, price, currency, active, sortOrder) VALUES (?, ?, ?, ?, ?, ?, ?)' + ) + .run( + id, + String(name || '').trim(), + parseInt(durationMinutes, 10) || 60, + parseFloat(price) || 0, + String(currency || 'EUR').toUpperCase().slice(0, 3), + active ? 1 : 0, + sortOrder + ); + return findPackageById(id); +} + +function updatePackage(id, updates) { + const current = findPackageById(id); + if (!current) return null; + const next = { + ...current, + ...(updates.name !== undefined && { name: String(updates.name).trim() }), + ...(updates.durationMinutes !== undefined && { + durationMinutes: parseInt(updates.durationMinutes, 10) || current.durationMinutes + }), + ...(updates.price !== undefined && { price: parseFloat(updates.price) ?? current.price }), + ...(updates.currency !== undefined && { + currency: String(updates.currency).toUpperCase().slice(0, 3) + }), + ...(updates.active !== undefined && { active: Boolean(updates.active) }), + ...(updates.sortOrder !== undefined && { sortOrder: parseInt(updates.sortOrder, 10) }) + }; + getDb() + .prepare( + 'UPDATE packages SET name = ?, durationMinutes = ?, price = ?, currency = ?, active = ?, sortOrder = ? WHERE id = ?' + ) + .run( + next.name, + next.durationMinutes, + next.price, + next.currency, + next.active ? 1 : 0, + next.sortOrder, + id + ); + return findPackageById(id); +} + +function deletePackage(id) { + const result = getDb().prepare('DELETE FROM packages WHERE id = ?').run(id); + return result.changes > 0; +} + +// --- Pending orders --- + +const PENDING_ORDER_TTL_MS = 30 * 60 * 1000; + +function addPendingOrder(orderId, { guestMac, packageId }) { + getDb() + .prepare( + 'INSERT OR REPLACE INTO pending_orders (orderId, guestMac, packageId, createdAt) VALUES (?, ?, ?, ?)' + ) + .run(orderId, guestMac, packageId, new Date().toISOString()); +} + +function getAndRemovePendingOrder(orderId) { + const row = getDb() + .prepare('SELECT orderId, guestMac, packageId, createdAt FROM pending_orders WHERE orderId = ?') + .get(orderId); + if (!row) return null; + const created = new Date(row.createdAt).getTime(); + if (Date.now() - created > PENDING_ORDER_TTL_MS) { + getDb().prepare('DELETE FROM pending_orders WHERE orderId = ?').run(orderId); + return null; + } + getDb().prepare('DELETE FROM pending_orders WHERE orderId = ?').run(orderId); + return { + guestMac: row.guestMac, + packageId: row.packageId, + createdAt: row.createdAt + }; +} + +module.exports = { + getDb, + loadCodes, + saveCodes, + findAndValidateCode, + incrementCodeUseCount, + addCode, + deleteCode, + loadPackages, + savePackages, + findPackageById, + addPackage, + updatePackage, + deletePackage, + addPendingOrder, + getAndRemovePendingOrder, + PENDING_ORDER_TTL_MS +}; diff --git a/package.json b/package.json index 2361b9d..b569603 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "MIT", "dependencies": { "axios": "^1.6.0", + "better-sqlite3": "^12.6.2", "dotenv": "^17.2.4", "express": "^4.18.2", "express-session": "^1.17.3" diff --git a/server.js b/server.js index 47a1e74..4112414 100644 --- a/server.js +++ b/server.js @@ -3,211 +3,16 @@ const express = require('express'); const axios = require('axios'); const https = require('https'); const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); const session = require('express-session'); +const db = require('./db'); + const app = express(); const PORT = 80; // Admin Configuration const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'change-this-admin-password'; -// Code storage (data/codes.json) -const DATA_DIR = path.join(__dirname, 'data'); -const CODES_FILE = path.join(DATA_DIR, 'codes.json'); -// 8-char alphanumeric, excludes confusing chars (0, O, 1, I, l) -const CODE_CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; - -function ensureDataDir() { - if (!fs.existsSync(DATA_DIR)) { - fs.mkdirSync(DATA_DIR, { recursive: true }); - } -} - -function loadCodes() { - ensureDataDir(); - if (!fs.existsSync(CODES_FILE)) { - return []; - } - try { - const data = fs.readFileSync(CODES_FILE, 'utf8'); - return JSON.parse(data); - } catch (err) { - console.error('Error loading codes:', err); - return []; - } -} - -function saveCodes(codes) { - ensureDataDir(); - fs.writeFileSync(CODES_FILE, JSON.stringify(codes, null, 2), 'utf8'); -} - -function generateCode() { - let code = ''; - for (let i = 0; i < 8; i++) { - code += CODE_CHARS.charAt(Math.floor(Math.random() * CODE_CHARS.length)); - } - return code; -} - -function findAndValidateCode(codeInput) { - const codes = loadCodes(); - const normalized = String(codeInput || '').trim().toUpperCase(); - if (!normalized) return null; - const entry = codes.find((c) => c.code === normalized); - if (!entry) return null; - if (entry.expiresAt && new Date(entry.expiresAt) < new Date()) { - return null; // expired - } - return entry; -} - -function incrementCodeUseCount(codeInput) { - const codes = loadCodes(); - const normalized = String(codeInput || '').trim().toUpperCase(); - const idx = codes.findIndex((c) => c.code === normalized); - if (idx === -1) return; - codes[idx].useCount = (codes[idx].useCount || 0) + 1; - saveCodes(codes); -} - -function addCode(expiresInMinutes) { - const code = generateCode(); - const now = new Date(); - const expiresAt = expiresInMinutes - ? new Date(now.getTime() + expiresInMinutes * 60 * 1000) - : null; - const entry = { - code, - createdAt: now.toISOString(), - expiresAt: expiresAt ? expiresAt.toISOString() : null, - useCount: 0 - }; - const codes = loadCodes(); - codes.push(entry); - saveCodes(codes); - return entry; -} - -function deleteCode(codeInput) { - const codes = loadCodes(); - const normalized = String(codeInput || '').trim().toUpperCase(); - const filtered = codes.filter((c) => c.code !== normalized); - if (filtered.length === codes.length) return false; - saveCodes(filtered); - return true; -} - -// Packages storage (data/packages.json) -const PACKAGES_FILE = path.join(DATA_DIR, 'packages.json'); -const PENDING_ORDERS_FILE = path.join(DATA_DIR, 'pending-orders.json'); -const PENDING_ORDER_TTL_MS = 30 * 60 * 1000; // 30 minutes - -function loadPackages() { - ensureDataDir(); - if (!fs.existsSync(PACKAGES_FILE)) return []; - try { - return JSON.parse(fs.readFileSync(PACKAGES_FILE, 'utf8')); - } catch (err) { - console.error('Error loading packages:', err); - return []; - } -} - -function savePackages(packages) { - ensureDataDir(); - fs.writeFileSync(PACKAGES_FILE, JSON.stringify(packages, null, 2), 'utf8'); -} - -function findPackageById(id) { - return loadPackages().find((p) => p.id === id); -} - -function addPackage({ name, durationMinutes, price, currency, active = true }) { - const packages = loadPackages(); - const maxOrder = packages.reduce((m, p) => Math.max(m, p.sortOrder || 0), 0); - const entry = { - id: crypto.randomUUID(), - name: String(name || '').trim(), - durationMinutes: parseInt(durationMinutes, 10) || 60, - price: parseFloat(price) || 0, - currency: String(currency || 'EUR').toUpperCase().slice(0, 3), - active: Boolean(active), - sortOrder: maxOrder + 1 - }; - packages.push(entry); - savePackages(packages); - return entry; -} - -function updatePackage(id, updates) { - const packages = loadPackages(); - const idx = packages.findIndex((p) => p.id === id); - if (idx === -1) return null; - packages[idx] = { - ...packages[idx], - ...(updates.name !== undefined && { name: String(updates.name).trim() }), - ...(updates.durationMinutes !== undefined && { durationMinutes: parseInt(updates.durationMinutes, 10) || packages[idx].durationMinutes }), - ...(updates.price !== undefined && { price: parseFloat(updates.price) || packages[idx].price }), - ...(updates.currency !== undefined && { currency: String(updates.currency).toUpperCase().slice(0, 3) }), - ...(updates.active !== undefined && { active: Boolean(updates.active) }), - ...(updates.sortOrder !== undefined && { sortOrder: parseInt(updates.sortOrder, 10) }) - }; - savePackages(packages); - return packages[idx]; -} - -function deletePackage(id) { - const packages = loadPackages(); - const filtered = packages.filter((p) => p.id !== id); - if (filtered.length === packages.length) return false; - savePackages(filtered); - return true; -} - -function loadPendingOrders() { - ensureDataDir(); - if (!fs.existsSync(PENDING_ORDERS_FILE)) return {}; - try { - return JSON.parse(fs.readFileSync(PENDING_ORDERS_FILE, 'utf8')); - } catch (err) { - console.error('Error loading pending orders:', err); - return {}; - } -} - -function savePendingOrders(obj) { - ensureDataDir(); - fs.writeFileSync(PENDING_ORDERS_FILE, JSON.stringify(obj, null, 2), 'utf8'); -} - -function addPendingOrder(orderId, { guestMac, packageId }) { - const orders = loadPendingOrders(); - orders[orderId] = { - guestMac, - packageId, - createdAt: new Date().toISOString() - }; - savePendingOrders(orders); -} - -function getAndRemovePendingOrder(orderId) { - const orders = loadPendingOrders(); - const entry = orders[orderId]; - if (!entry) return null; - const created = new Date(entry.createdAt).getTime(); - if (Date.now() - created > PENDING_ORDER_TTL_MS) { - delete orders[orderId]; - savePendingOrders(orders); - return null; - } - delete orders[orderId]; - savePendingOrders(orders); - return entry; -} - // Telegram Configuration const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; @@ -328,7 +133,7 @@ app.post('/authorize', async (req, res) => { }); } - const codeEntry = findAndValidateCode(code); + const codeEntry = db.findAndValidateCode(code); if (!codeEntry) { return res.status(400).json({ success: false, @@ -350,7 +155,7 @@ app.post('/authorize', async (req, res) => { console.log('Authorization successful!', authorizeResponse.data); - incrementCodeUseCount(code); + db.incrementCodeUseCount(code); res.json({ success: true, @@ -394,7 +199,7 @@ app.get('/admin/check', (req, res) => { }); app.get('/admin/codes', requireAdmin, (req, res) => { - const codes = loadCodes(); + const codes = db.loadCodes(); const now = new Date(); const filtered = codes.map((c) => ({ ...c, @@ -405,12 +210,12 @@ app.get('/admin/codes', requireAdmin, (req, res) => { app.post('/admin/codes', requireAdmin, (req, res) => { const { expiresInMinutes } = req.body || {}; - const entry = addCode(expiresInMinutes != null ? Number(expiresInMinutes) : null); + const entry = db.addCode(expiresInMinutes != null ? Number(expiresInMinutes) : null); res.json({ success: true, code: entry.code, entry }); }); app.delete('/admin/codes/:code', requireAdmin, (req, res) => { - const deleted = deleteCode(req.params.code); + const deleted = db.deleteCode(req.params.code); if (deleted) { return res.json({ success: true }); } @@ -419,7 +224,7 @@ app.delete('/admin/codes/:code', requireAdmin, (req, res) => { // Public packages (active only) app.get('/api/packages', (req, res) => { - const packages = loadPackages() + const packages = db.loadPackages() .filter((p) => p.active) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map((p) => ({ @@ -434,7 +239,7 @@ app.get('/api/packages', (req, res) => { // Admin packages CRUD app.get('/admin/packages', requireAdmin, (req, res) => { - const packages = loadPackages().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + const packages = db.loadPackages().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); res.json({ packages }); }); @@ -443,18 +248,18 @@ app.post('/admin/packages', requireAdmin, (req, res) => { if (!name || !durationMinutes || price == null) { return res.status(400).json({ success: false, error: 'Missing required fields: name, durationMinutes, price' }); } - const entry = addPackage({ name, durationMinutes, price, currency: currency || 'EUR', active }); + const entry = db.addPackage({ name, durationMinutes, price, currency: currency || 'EUR', active }); res.json({ success: true, package: entry }); }); app.put('/admin/packages/:id', requireAdmin, (req, res) => { - const pkg = updatePackage(req.params.id, req.body || {}); + const pkg = db.updatePackage(req.params.id, req.body || {}); if (!pkg) return res.status(404).json({ success: false, error: 'Package not found' }); res.json({ success: true, package: pkg }); }); app.delete('/admin/packages/:id', requireAdmin, (req, res) => { - const deleted = deletePackage(req.params.id); + const deleted = db.deletePackage(req.params.id); if (!deleted) return res.status(404).json({ success: false, error: 'Package not found' }); res.json({ success: true }); }); @@ -466,7 +271,7 @@ app.post('/api/paypal/create-order', async (req, res) => { return res.status(400).json({ success: false, error: 'Session expired. Please reconnect to WiFi.' }); } const { packageId } = req.body || {}; - const pkg = findPackageById(packageId); + const pkg = db.findPackageById(packageId); if (!pkg || !pkg.active) { return res.status(400).json({ success: false, error: 'Invalid package' }); } @@ -497,7 +302,7 @@ app.post('/api/paypal/create-order', async (req, res) => { } ); const orderId = resPayPal.data.id; - addPendingOrder(orderId, { + db.addPendingOrder(orderId, { guestMac: req.session.guestMac, packageId: pkg.id }); @@ -523,12 +328,12 @@ app.post('/api/paypal/capture-order', async (req, res) => { return res.status(400).json({ success: false, error: 'Missing orderId' }); } - const pending = getAndRemovePendingOrder(orderId); + const pending = db.getAndRemovePendingOrder(orderId); if (!pending || pending.guestMac !== guestMac) { return res.status(400).json({ success: false, error: 'Invalid or expired order' }); } - const pkg = findPackageById(pending.packageId); + const pkg = db.findPackageById(pending.packageId); if (!pkg) { return res.status(400).json({ success: false, error: 'Package no longer available' }); } @@ -550,7 +355,7 @@ app.post('/api/paypal/capture-order', async (req, res) => { return res.status(400).json({ success: false, error: 'Payment not completed' }); } - addCode(pkg.durationMinutes); // record for audit + db.addCode(pkg.durationMinutes); // record for audit const amount = `${Number(pkg.price).toFixed(2)} ${pkg.currency}`; const durationText = pkg.durationMinutes >= 1440