From 2376cf16c7975fcb3204ba44a9ad01ed900b39d2 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Wed, 11 Feb 2026 23:13:25 +0100 Subject: [PATCH] Inital Commit --- .gitignore | 5 + README.md | 250 +++++++++++++++ package.json | 27 ++ public/admin.html | 690 +++++++++++++++++++++++++++++++++++++++++ public/index.html | 721 +++++++++++++++++++++++++++++++++++++++++++ server.js | 601 ++++++++++++++++++++++++++++++++++++ unifi-portal.service | 20 ++ 7 files changed, 2314 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/admin.html create mode 100644 public/index.html create mode 100644 server.js create mode 100644 unifi-portal.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82e8e73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +node_modules/ +package-lock.json +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f111e5 --- /dev/null +++ b/README.md @@ -0,0 +1,250 @@ +# UniFi Guest Portal + +A custom external portal server for UniFi guest WiFi networks with a modern, responsive design. + +## Features + +- 🎨 Modern, mobile-responsive design +- 🔒 Secure authentication with UniFi Controller +- ⚡ Fast and lightweight +- 🎯 Easy to customize +- ⏱️ Configurable access duration (default: 24 hours) + +## Prerequisites + +- Node.js 16+ installed +- UniFi Controller with admin credentials +- Proxmox LXC container (or any Linux server) +- Reverse proxy with SSL (Zoraxy in your case) +- Local DNS resolution (Pi-hole) + +## Installation + +### 1. Create LXC Container on Proxmox + +```bash +# Create Ubuntu/Debian container +# Assign static IP (e.g., 192.168.1.100) +# Update the system +apt update && apt upgrade -y + +# Install Node.js +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +apt install -y nodejs git + +# Verify installation +node --version +npm --version +``` + +### 2. Deploy the Portal Application + +```bash +# Upload the unifi-portal folder to your container +# Or clone from your git repository +cd /opt +# Copy the unifi-portal folder here + +# Navigate to project directory +cd unifi-portal + +# Install dependencies +npm install + +# Configure environment variables +cp .env.example .env +nano .env # Edit with your actual credentials +``` + +### 3. Configure UniFi Controller Credentials + +Edit `server.js` and update these values: + +```javascript +const UNIFI_USERNAME = 'your-unifi-admin-username'; +const UNIFI_PASSWORD = 'your-unifi-admin-password'; +const UNIFI_SITE = 'default'; // or your site name +``` + +### 4. Test the Application + +```bash +# Start the server +npm start + +# Test from another machine +curl http://192.168.1.100:3000/health +``` + +### 5. Set up as a System Service + +Create a systemd service file: + +```bash +nano /etc/systemd/system/unifi-portal.service +``` + +Add this content: + +```ini +[Unit] +Description=UniFi Guest Portal +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/unifi-portal +ExecStart=/usr/bin/node server.js +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + + +```bash +systemctl daemon-reload +systemctl enable unifi-portal +systemctl start unifi-portal +systemctl status unifi-portal +``` + +### 6. Configure Pi-hole DNS + +Add a local DNS record in Pi-hole: +- Domain: `portal.yourdomain.local` (or whatever you prefer) +- IP: `192.168.1.100` (your LXC container IP) + +### 7. Configure Zoraxy Reverse Proxy + +In Zoraxy: +1. Create a new proxy rule +2. Domain: `portal.yourdomain.local` +3. Target: `http://192.168.1.100:3000` +4. Enable SSL (use self-signed cert or Let's Encrypt) +5. Save and test + +### 8. Configure UniFi Controller + +1. Log into your UniFi Controller at `https://192.168.1.1` +2. Go to **Settings** → **Guest Control** +3. Create or edit a Guest Hotspot/Portal +4. Set **Authentication** to **External Portal Server** +5. **External Portal Server URL**: `https://portal.yourdomain.local` +6. Set **HTTPS Redirection**: Enabled +7. Save settings + +## Testing + +1. Connect a device to your guest WiFi network +2. Open a browser - you should be redirected to your custom portal +3. Click "Connect to WiFi" +4. You should get internet access and be redirected + +## Customization + +### Change the Design + +Edit `public/index.html` to customize: +- Colors (gradient, buttons, etc.) +- Logo (change the emoji or add an image) +- Text and messaging +- Add form fields (email capture, terms checkbox, etc.) + +### Adjust Access Duration + +In `server.js`, modify the minutes parameter: + +```javascript +minutes: 1440 // 24 hours (1440 minutes) +``` + +Common values: +- 60 = 1 hour +- 480 = 8 hours +- 1440 = 24 hours +- 10080 = 1 week + +### Add Email Collection + +You can add a form field to collect emails before authorization. Example modification to `public/index.html`: + +```html + +``` + +And modify the `/authorize` endpoint in `server.js` to save the email. + +## Troubleshooting + +### Portal doesn't redirect +- Check UniFi Controller external portal URL is correct +- Verify DNS resolution: `nslookup portal.yourdomain.local` +- Check Zoraxy proxy rule is active + +### "Authorization failed" +- Verify UniFi credentials in `server.js` +- Check UniFi Controller is accessible from container: `curl -k https://192.168.1.1` +- Review logs: `journalctl -u unifi-portal -f` + +### SSL Certificate errors +- UniFi Controller uses self-signed cert by default (expected) +- The code ignores cert errors with `rejectUnauthorized: false` +- For production, consider proper SSL certificates + +### Check logs +```bash +# View service logs +journalctl -u unifi-portal -f + +# Or if running manually +npm start +``` + +## Security Notes + +1. The portal server needs admin credentials to the UniFi Controller +2. Store credentials securely (consider using environment variables) +3. Use HTTPS for your portal (required for most mobile devices) +4. The code currently disables SSL verification for the UniFi Controller (common for self-signed certs) +5. Consider implementing rate limiting to prevent abuse + +## Project Structure + +``` +unifi-portal/ +├── server.js # Main Express server with UniFi API integration +├── package.json # Node.js dependencies +├── public/ +│ └── index.html # Custom landing page +├── .env.example # Environment variables template +└── README.md # This file +``` + +## API Endpoints + +- `GET /` - Main portal page (receives UniFi redirect parameters) +- `POST /authorize` - Authorizes guest device with UniFi Controller +- `GET /health` - Health check endpoint + +## UniFi API Reference + +The portal uses these UniFi Controller API endpoints: +- `POST /api/login` - Authenticate to controller +- `POST /api/s/{site}/cmd/stamgr` - Authorize guest device +- `POST /api/logout` - Logout from controller + +## License + +MIT + +## Support + +For issues or questions, check: +- UniFi API documentation +- Node.js/Express.js documentation +- Your network configuration (DNS, firewall, routing) diff --git a/package.json b/package.json new file mode 100644 index 0000000..2361b9d --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "unifi-guest-portal", + "version": "1.0.0", + "description": "Custom UniFi Guest Portal with External Authentication", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "keywords": [ + "unifi", + "guest", + "portal", + "hotspot" + ], + "author": "", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "dotenv": "^17.2.4", + "express": "^4.18.2", + "express-session": "^1.17.3" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..d31bcb3 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,690 @@ + + + + + + Admin - Obi-WLANKenobi Portal + + + +
+
+

Admin Login

+
+
+
+ + +
+ +
+
+ +
+
+

Portal Admin

+ +
+ +
Generate New Code
+
+
+
+ + +
+ +
+ + + +
Pakete & Preise
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ + + + + + + + + + + + +
NameDauerPreisAktiv
+
Keine Pakete. Erstelle eines oben.
+ +
Active Codes
+
+ + + + + + + + + + + +
CodeUsesExpires
+
No codes yet. Generate one above.
+
+
+
+ + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cbe468e --- /dev/null +++ b/public/index.html @@ -0,0 +1,721 @@ + + + + + + Obi-WLANKenobi - Guest Portal + + + +
+ +
+ +

OBI-WLANKENOBI

+

A Guest Portal From A More Civilized Age

+

"Hello there! The Force is strong with this connection."

+ +
+
+ + + + Faster than the Millennium Falcon +
+
+ + + + Protected by Jedi encryption +
+
+ + + + 24 parsecs of connectivity +
+
+ +
+ + +
+ +
+
+
+ +
+ + + + +
+ +
+ +
+ By connecting, you agree this is the way. May the WiFi be with you. +
+
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..47a1e74 --- /dev/null +++ b/server.js @@ -0,0 +1,601 @@ +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`); +}); diff --git a/unifi-portal.service b/unifi-portal.service new file mode 100644 index 0000000..77a3d70 --- /dev/null +++ b/unifi-portal.service @@ -0,0 +1,20 @@ +[Unit] +Description=UniFi Guest Portal +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/unifi-portal +ExecStart=/usr/bin/node server.js +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Security settings +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target