require('dotenv').config(); 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 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; async function sendTelegramNotification(text) { if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return; try { await axios.post( `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { chat_id: TELEGRAM_CHAT_ID, text: text, parse_mode: 'HTML' }, { timeout: 15000 } ); } catch (err) { console.error('Telegram notification error:', err.message, err.response?.status ? `(HTTP ${err.response.status})` : ''); } } // PayPal Configuration const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID; const PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET; const PAYPAL_MODE = (process.env.PAYPAL_MODE || 'sandbox').toLowerCase(); const PAYPAL_BASE_URL = PAYPAL_MODE === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; async function getPayPalAccessToken() { const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_CLIENT_SECRET}`).toString('base64'); const res = await axios.post( `${PAYPAL_BASE_URL}/v1/oauth2/token`, 'grant_type=client_credentials', { headers: { 'Authorization': `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' } } ); return res.data.access_token; } // UniFi Controller Configuration (from .env) const UNIFI_CONTROLLER = process.env.UNIFI_CONTROLLER || 'https://192.168.1.1'; const UNIFI_API_KEY = process.env.UNIFI_API_KEY; const UNIFI_SITE = process.env.UNIFI_SITE || 'default'; // Create axios instance that ignores SSL certificate errors (for self-signed certs) const unifiAgent = new https.Agent({ rejectUnauthorized: false }); const unifiApi = axios.create({ httpsAgent: unifiAgent, headers: { 'X-API-KEY': UNIFI_API_KEY, 'Content-Type': 'application/json' } }); // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(express.static('public')); app.use(session({ secret: process.env.SESSION_SECRET || 'change-this-secret', resave: false, saveUninitialized: true, cookie: { secure: false } // Set to true if using HTTPS })); // Admin middleware function requireAdmin(req, res, next) { if (req.session && req.session.admin) return next(); if (req.xhr || req.headers.accept?.includes('application/json')) { return res.status(401).json({ error: 'Unauthorized' }); } res.redirect('/admin'); } // Portal handler function function handlePortal(req, res) { // Capture UniFi redirect parameters const { id, ap, t, url, ssid } = req.query; if (!id) { return res.send('Missing required parameters. Please connect to the WiFi network.'); } // Store guest info in session req.session.guestMac = id; req.session.apMac = ap; req.session.redirectUrl = url || 'https://google.com'; req.session.ssid = ssid; res.sendFile(path.join(__dirname, 'public', 'index.html')); } // Main portal page app.get('/', handlePortal); // UniFi Guest Portal paths (UniFi redirects to /guest/s//) app.get('/guest/s/:site', handlePortal); app.get('/guest/s/:site/', handlePortal); // Authorize guest endpoint app.post('/authorize', async (req, res) => { const guestMac = req.session.guestMac; const redirectUrl = req.session.redirectUrl; const { code } = req.body || {}; if (!guestMac) { return res.status(400).json({ success: false, message: 'Session expired. Please reconnect to WiFi.' }); } const codeEntry = findAndValidateCode(code); if (!codeEntry) { return res.status(400).json({ success: false, message: 'Invalid or expired code.' }); } try { // Authorize the guest device using API key console.log(`Authorizing device: ${guestMac}`); const authorizeResponse = await unifiApi.post( `${UNIFI_CONTROLLER}/proxy/network/api/s/${UNIFI_SITE}/cmd/stamgr`, { cmd: 'authorize-guest', mac: guestMac.toLowerCase(), minutes: parseInt(process.env.ACCESS_DURATION) || 1440 } ); console.log('Authorization successful!', authorizeResponse.data); incrementCodeUseCount(code); res.json({ success: true, message: 'Connected! Redirecting...', redirectUrl: redirectUrl }); } catch (error) { console.error('Authorization error:', error.response?.data || error.message); res.status(500).json({ success: false, message: 'Failed to authorize. Please try again.' }); } }); // Admin routes app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); app.post('/admin/login', (req, res) => { const { password } = req.body || {}; if (password === ADMIN_PASSWORD) { req.session.admin = true; return res.json({ success: true }); } res.status(401).json({ success: false, error: 'Invalid password' }); }); app.post('/admin/logout', (req, res) => { req.session.admin = false; res.json({ success: true }); }); app.get('/admin/check', (req, res) => { if (req.session && req.session.admin) { return res.json({ authenticated: true }); } res.json({ authenticated: false }); }); app.get('/admin/codes', requireAdmin, (req, res) => { const codes = loadCodes(); const now = new Date(); const filtered = codes.map((c) => ({ ...c, expired: c.expiresAt ? new Date(c.expiresAt) < now : false })); res.json({ codes: filtered }); }); app.post('/admin/codes', requireAdmin, (req, res) => { const { expiresInMinutes } = req.body || {}; const entry = 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); if (deleted) { return res.json({ success: true }); } res.status(404).json({ success: false, error: 'Code not found' }); }); // Public packages (active only) app.get('/api/packages', (req, res) => { const packages = loadPackages() .filter((p) => p.active) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .map((p) => ({ id: p.id, name: p.name, durationMinutes: p.durationMinutes, price: p.price, currency: p.currency })); res.json({ packages }); }); // Admin packages CRUD app.get('/admin/packages', requireAdmin, (req, res) => { const packages = loadPackages().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); res.json({ packages }); }); app.post('/admin/packages', requireAdmin, (req, res) => { const { name, durationMinutes, price, currency, active } = req.body || {}; 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 }); res.json({ success: true, package: entry }); }); app.put('/admin/packages/:id', requireAdmin, (req, res) => { const pkg = 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); if (!deleted) return res.status(404).json({ success: false, error: 'Package not found' }); res.json({ success: true }); }); // PayPal endpoints app.post('/api/paypal/create-order', async (req, res) => { const guestMac = req.session?.guestMac; if (!guestMac) { return res.status(400).json({ success: false, error: 'Session expired. Please reconnect to WiFi.' }); } const { packageId } = req.body || {}; const pkg = findPackageById(packageId); if (!pkg || !pkg.active) { return res.status(400).json({ success: false, error: 'Invalid package' }); } if (!PAYPAL_CLIENT_ID || !PAYPAL_CLIENT_SECRET) { return res.status(500).json({ success: false, error: 'PayPal not configured' }); } try { const token = await getPayPalAccessToken(); const amount = String(Number(pkg.price).toFixed(2)); const resPayPal = await axios.post( `${PAYPAL_BASE_URL}/v2/checkout/orders`, { intent: 'CAPTURE', purchase_units: [{ amount: { currency_code: pkg.currency, value: amount }, description: pkg.name }] }, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } } ); const orderId = resPayPal.data.id; addPendingOrder(orderId, { guestMac: req.session.guestMac, packageId: pkg.id }); res.json({ success: true, orderId, clientId: PAYPAL_CLIENT_ID }); } catch (err) { console.error('PayPal create-order error:', err.response?.data || err.message); res.status(500).json({ success: false, error: 'Failed to create order' }); } }); app.post('/api/paypal/capture-order', async (req, res) => { const guestMac = req.session?.guestMac; const redirectUrl = req.session?.redirectUrl || 'https://google.com'; if (!guestMac) { return res.status(400).json({ success: false, error: 'Session expired. Please reconnect to WiFi.' }); } const { orderId } = req.body || {}; if (!orderId) { return res.status(400).json({ success: false, error: 'Missing orderId' }); } const pending = getAndRemovePendingOrder(orderId); if (!pending || pending.guestMac !== guestMac) { return res.status(400).json({ success: false, error: 'Invalid or expired order' }); } const pkg = findPackageById(pending.packageId); if (!pkg) { return res.status(400).json({ success: false, error: 'Package no longer available' }); } try { const token = await getPayPalAccessToken(); const resPayPal = await axios.post( `${PAYPAL_BASE_URL}/v2/checkout/orders/${orderId}/capture`, {}, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } } ); if (resPayPal.data.status !== 'COMPLETED') { return res.status(400).json({ success: false, error: 'Payment not completed' }); } addCode(pkg.durationMinutes); // record for audit const amount = `${Number(pkg.price).toFixed(2)} ${pkg.currency}`; const durationText = pkg.durationMinutes >= 1440 ? `${pkg.durationMinutes / 1440} Tage` : pkg.durationMinutes >= 60 ? `${pkg.durationMinutes / 60} Stunden` : `${pkg.durationMinutes} Min`; sendTelegramNotification( `🛒 Neuer Zugangskauf\n\n` + `Paket: ${pkg.name}\n` + `Preis: ${amount}\n` + `Dauer: ${durationText}` ).catch(() => {}); await unifiApi.post( `${UNIFI_CONTROLLER}/proxy/network/api/s/${UNIFI_SITE}/cmd/stamgr`, { cmd: 'authorize-guest', mac: guestMac.toLowerCase(), minutes: pkg.durationMinutes } ); res.json({ success: true, message: 'Connected! Redirecting...', redirectUrl }); } catch (err) { console.error('PayPal capture-order error:', err.response?.data || err.message); res.status(500).json({ success: false, error: 'Failed to capture payment' }); } }); // PayPal client ID for frontend SDK app.get('/api/paypal/config', (req, res) => { res.json({ clientId: PAYPAL_CLIENT_ID || '' }); }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.listen(PORT, '0.0.0.0', () => { console.log(`UniFi Guest Portal running on port ${PORT}`); console.log(`Make sure to configure UniFi to redirect to this portal`); });