Files
Unifi-Portal/server.js
Carsten Graf 2376cf16c7 Inital Commit
2026-02-11 23:13:25 +01:00

602 lines
18 KiB
JavaScript

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/<site>/)
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(
`🛒 <b>Neuer Zugangskauf</b>\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`);
});