407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const axios = require('axios');
|
|
const https = require('https');
|
|
const path = require('path');
|
|
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';
|
|
|
|
// 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 = db.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);
|
|
|
|
db.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 = db.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 = 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 = db.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 = db.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 = db.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 = 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 = 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 = db.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 = db.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;
|
|
db.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 = db.getAndRemovePendingOrder(orderId);
|
|
if (!pending || pending.guestMac !== guestMac) {
|
|
return res.status(400).json({ success: false, error: 'Invalid or expired order' });
|
|
}
|
|
|
|
const pkg = db.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' });
|
|
}
|
|
|
|
db.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`);
|
|
});
|