Inital Commit

This commit is contained in:
Carsten Graf
2026-02-11 23:13:25 +01:00
commit 2376cf16c7
7 changed files with 2314 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
data/
node_modules/
package-lock.json
.DS_Store

250
README.md Normal file
View File

@@ -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
<input type="email" id="guestEmail" placeholder="Enter your email" required>
```
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)

27
package.json Normal file
View File

@@ -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"
}
}

690
public/admin.html Normal file
View File

@@ -0,0 +1,690 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Obi-WLANKenobi Portal</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Orbitron', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000000;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at top, rgba(13, 110, 253, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at bottom, rgba(13, 110, 253, 0.1) 0%, transparent 50%);
pointer-events: none;
}
.container {
background: rgba(10, 25, 47, 0.95);
border: 2px solid rgba(13, 110, 253, 0.5);
border-radius: 20px;
box-shadow:
0 0 60px rgba(13, 110, 253, 0.4),
inset 0 0 60px rgba(13, 110, 253, 0.1);
padding: 40px;
max-width: 700px;
width: 100%;
position: relative;
z-index: 10;
}
h1 {
color: #0d6efd;
font-size: 28px;
margin-bottom: 24px;
font-weight: 900;
text-shadow: 0 0 20px rgba(13, 110, 253, 0.8);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
color: #6ea8fe;
font-size: 14px;
margin-bottom: 8px;
}
input, select {
width: 100%;
padding: 12px 16px;
background: rgba(13, 110, 253, 0.1);
border: 2px solid rgba(13, 110, 253, 0.5);
border-radius: 8px;
color: #fff;
font-family: 'Orbitron', sans-serif;
font-size: 16px;
}
input::placeholder {
color: rgba(163, 207, 255, 0.5);
}
input:focus, select:focus {
outline: none;
border-color: #0d6efd;
box-shadow: 0 0 15px rgba(13, 110, 253, 0.5);
}
select option {
background: #0a192f;
color: #fff;
}
.btn {
padding: 14px 28px;
border-radius: 8px;
font-family: 'Orbitron', sans-serif;
font-weight: 700;
font-size: 14px;
cursor: pointer;
border: 2px solid;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
color: white;
border-color: #0d6efd;
width: 100%;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(13, 110, 253, 0.6);
}
.btn-secondary {
background: transparent;
color: #6ea8fe;
border-color: rgba(13, 110, 253, 0.5);
}
.btn-secondary:hover {
background: rgba(13, 110, 253, 0.2);
}
.btn-danger {
background: transparent;
color: #ea868f;
border-color: rgba(220, 53, 69, 0.5);
padding: 8px 14px;
font-size: 12px;
}
.btn-danger:hover {
background: rgba(220, 53, 69, 0.2);
}
.message {
padding: 12px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.message.error {
background: rgba(220, 53, 69, 0.2);
color: #ea868f;
border: 1px solid rgba(220, 53, 69, 0.5);
}
.message.success {
background: rgba(13, 110, 253, 0.2);
color: #6ea8fe;
border: 1px solid rgba(13, 110, 253, 0.5);
}
.code-display {
background: rgba(13, 110, 253, 0.15);
border: 2px solid rgba(13, 110, 253, 0.5);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.code-display .code-value {
font-size: 32px;
font-weight: 900;
color: #0d6efd;
letter-spacing: 4px;
margin-bottom: 12px;
}
.code-display .hint {
font-size: 12px;
color: #6ea8fe;
}
.codes-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.codes-table th,
.codes-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid rgba(13, 110, 253, 0.2);
color: #a3cfff;
}
.codes-table th {
color: #0d6efd;
font-size: 12px;
text-transform: uppercase;
}
.codes-table .code-cell {
font-family: monospace;
font-weight: 700;
letter-spacing: 1px;
}
.codes-table .expired {
color: #ea868f;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-row h1 {
margin-bottom: 0;
}
#loginView, #dashboardView {
display: none;
}
#loginView.visible, #dashboardView.visible {
display: block;
}
.section-title {
color: #6ea8fe;
font-size: 16px;
margin: 24px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(13, 110, 253, 0.3);
}
.section-title:first-of-type {
margin-top: 0;
}
.empty-state {
color: #6ea8fe;
font-size: 14px;
padding: 24px;
text-align: center;
}
.form-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.form-row .form-group {
flex: 1;
min-width: 120px;
}
input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.packages-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.packages-table th,
.packages-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(13, 110, 253, 0.2);
color: #a3cfff;
}
.packages-table th {
color: #0d6efd;
font-size: 12px;
text-transform: uppercase;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div id="loginView" class="visible">
<h1>Admin Login</h1>
<div class="message" id="loginError"></div>
<form id="loginForm" onsubmit="return handleLogin(event)">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter admin password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
<div id="dashboardView">
<div class="header-row">
<h1>Portal Admin</h1>
<button type="button" class="btn btn-secondary" onclick="handleLogout()">Logout</button>
</div>
<div class="section-title">Generate New Code</div>
<div class="message" id="genMessage"></div>
<form id="generateForm" onsubmit="return handleGenerate(event)">
<div class="form-group">
<label for="expiry">Expiration</label>
<select id="expiry" name="expiry">
<option value="60">1 hour</option>
<option value="360">6 hours</option>
<option value="720">12 hours</option>
<option value="1440" selected>24 hours</option>
<option value="2880">48 hours</option>
<option value="10080">7 days</option>
<option value="">No expiry</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Generate Code</button>
</form>
<div id="newCodeDisplay" class="code-display" style="display: none;">
<div class="code-value" id="newCodeValue"></div>
<button type="button" class="btn btn-secondary" onclick="copyNewCode()">Copy to clipboard</button>
<div class="hint" style="margin-top: 8px;">Share this code with WiFi guests. They enter it on the portal to connect.</div>
</div>
<div class="section-title">Pakete & Preise</div>
<div class="message" id="pkgMessage"></div>
<form id="packageForm" onsubmit="return handlePackageSubmit(event)">
<input type="hidden" id="packageId" name="packageId" value="">
<div class="form-row">
<div class="form-group">
<label for="pkgName">Name</label>
<input type="text" id="pkgName" name="pkgName" placeholder="z.B. 24 Stunden" required>
</div>
<div class="form-group">
<label for="pkgDuration">Dauer</label>
<select id="pkgDuration" name="pkgDuration">
<option value="60">1 Stunde</option>
<option value="360">6 Stunden</option>
<option value="720">12 Stunden</option>
<option value="1440" selected>24 Stunden</option>
<option value="2880">48 Stunden</option>
<option value="10080">7 Tage</option>
<option value="43200">30 Tage</option>
</select>
</div>
<div class="form-group">
<label for="pkgPrice">Preis</label>
<input type="number" id="pkgPrice" name="pkgPrice" placeholder="2.99" step="0.01" min="0" required>
</div>
<div class="form-group">
<label for="pkgCurrency">Währung</label>
<select id="pkgCurrency" name="pkgCurrency">
<option value="EUR" selected>EUR</option>
<option value="CHF">CHF</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div class="form-group">
<label><input type="checkbox" id="pkgActive" name="pkgActive" checked> Aktiv (im Portal sichtbar)</label>
</div>
<button type="submit" class="btn btn-primary" id="packageSubmitBtn">Paket hinzufügen</button>
<button type="button" class="btn btn-secondary" id="packageCancelBtn" style="display: none; margin-left: 8px;" onclick="cancelPackageEdit()">Abbrechen</button>
</form>
<table class="packages-table">
<thead>
<tr>
<th>Name</th>
<th>Dauer</th>
<th>Preis</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody id="packagesTableBody">
</tbody>
</table>
<div id="emptyPackages" class="empty-state">Keine Pakete. Erstelle eines oben.</div>
<div class="section-title">Active Codes</div>
<div id="codesList">
<table class="codes-table">
<thead>
<tr>
<th>Code</th>
<th>Uses</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody id="codesTableBody">
</tbody>
</table>
<div id="emptyCodes" class="empty-state">No codes yet. Generate one above.</div>
</div>
</div>
</div>
<script>
let lastGeneratedCode = '';
async function checkAuth() {
try {
const r = await fetch('/admin/check');
const data = await r.json();
if (data.authenticated) {
document.getElementById('loginView').classList.remove('visible');
document.getElementById('dashboardView').classList.add('visible');
loadCodes();
loadPackages();
} else {
document.getElementById('loginView').classList.add('visible');
document.getElementById('dashboardView').classList.remove('visible');
}
} catch (e) {
document.getElementById('loginView').classList.add('visible');
document.getElementById('dashboardView').classList.remove('visible');
}
}
async function handleLogin(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const errEl = document.getElementById('loginError');
errEl.style.display = 'none';
try {
const r = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await r.json();
if (data.success) {
document.getElementById('password').value = '';
checkAuth();
} else {
errEl.textContent = 'Invalid password.';
errEl.className = 'message error';
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = 'Login failed. Try again.';
errEl.className = 'message error';
errEl.style.display = 'block';
}
return false;
}
async function handleLogout() {
await fetch('/admin/logout', { method: 'POST' });
checkAuth();
}
async function handleGenerate(e) {
e.preventDefault();
const expiry = document.getElementById('expiry').value;
const msgEl = document.getElementById('genMessage');
msgEl.style.display = 'none';
try {
const r = await fetch('/admin/codes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expiresInMinutes: expiry ? parseInt(expiry, 10) : null
})
});
const data = await r.json();
if (data.success) {
lastGeneratedCode = data.code;
document.getElementById('newCodeValue').textContent = data.code;
document.getElementById('newCodeDisplay').style.display = 'block';
loadCodes();
msgEl.textContent = 'Code generated successfully.';
msgEl.className = 'message success';
msgEl.style.display = 'block';
}
} catch (e) {
msgEl.textContent = 'Failed to generate code.';
msgEl.className = 'message error';
msgEl.style.display = 'block';
}
return false;
}
function copyNewCode() {
if (!lastGeneratedCode) return;
navigator.clipboard.writeText(lastGeneratedCode).then(() => {
const btn = document.querySelector('#newCodeDisplay .btn-secondary');
const orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = orig; }, 1500);
});
}
async function loadCodes() {
try {
const r = await fetch('/admin/codes');
const data = await r.json();
const tbody = document.getElementById('codesTableBody');
const emptyEl = document.getElementById('emptyCodes');
tbody.innerHTML = '';
if (!data.codes || data.codes.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
const now = new Date();
data.codes.forEach((c) => {
const tr = document.createElement('tr');
const expiresAt = c.expiresAt ? new Date(c.expiresAt) : null;
const expired = c.expired || (expiresAt && expiresAt < now);
const expiresText = expiresAt
? (expired ? 'Expired' : formatDate(expiresAt))
: 'Never';
tr.innerHTML = `
<td class="code-cell ${expired ? 'expired' : ''}">${c.code}</td>
<td>${c.useCount || 0}</td>
<td class="${expired ? 'expired' : ''}">${expiresText}</td>
<td>
<button type="button" class="btn btn-danger" onclick="revokeCode('${c.code}')">Revoke</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
console.error('Failed to load codes', e);
}
}
function formatDate(d) {
return new Date(d).toLocaleString();
}
async function revokeCode(code) {
if (!confirm('Revoke this code? It will no longer work for new connections.')) return;
try {
const r = await fetch('/admin/codes/' + encodeURIComponent(code), {
method: 'DELETE'
});
const data = await r.json();
if (data.success) {
loadCodes();
}
} catch (e) {
console.error('Failed to revoke', e);
}
}
function formatDuration(min) {
if (min >= 43200) return (min / 1440) + ' Tage';
if (min >= 1440) return (min / 1440) + ' Tage';
if (min >= 60) return (min / 60) + ' Stunden';
return min + ' Min';
}
async function loadPackages() {
try {
const r = await fetch('/admin/packages');
const data = await r.json();
const tbody = document.getElementById('packagesTableBody');
const emptyEl = document.getElementById('emptyPackages');
tbody.innerHTML = '';
if (!data.packages || data.packages.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
data.packages.forEach((p) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.name}</td>
<td>${formatDuration(p.durationMinutes)}</td>
<td>${p.price} ${p.currency}</td>
<td>${p.active ? 'Ja' : 'Nein'}</td>
<td>
<button type="button" class="btn btn-secondary btn-small" onclick="editPackage('${p.id}')">Bearbeiten</button>
<button type="button" class="btn btn-danger btn-small" onclick="deletePackage('${p.id}')">Löschen</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
console.error('Failed to load packages', e);
}
}
async function handlePackageSubmit(e) {
e.preventDefault();
const id = document.getElementById('packageId').value;
const msgEl = document.getElementById('pkgMessage');
msgEl.style.display = 'none';
const body = {
name: document.getElementById('pkgName').value.trim(),
durationMinutes: parseInt(document.getElementById('pkgDuration').value, 10),
price: parseFloat(document.getElementById('pkgPrice').value),
currency: document.getElementById('pkgCurrency').value,
active: document.getElementById('pkgActive').checked
};
try {
const url = id ? '/admin/packages/' + encodeURIComponent(id) : '/admin/packages';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await r.json();
if (data.success) {
document.getElementById('packageForm').reset();
document.getElementById('packageId').value = '';
document.getElementById('pkgActive').checked = true;
document.getElementById('packageSubmitBtn').textContent = 'Paket hinzufügen';
document.getElementById('packageCancelBtn').style.display = 'none';
loadPackages();
msgEl.textContent = id ? 'Paket aktualisiert.' : 'Paket hinzugefügt.';
msgEl.className = 'message success';
msgEl.style.display = 'block';
} else {
msgEl.textContent = data.error || 'Fehler.';
msgEl.className = 'message error';
msgEl.style.display = 'block';
}
} catch (e) {
msgEl.textContent = 'Fehler beim Speichern.';
msgEl.className = 'message error';
msgEl.style.display = 'block';
}
return false;
}
function editPackage(id) {
fetch('/admin/packages')
.then(r => r.json())
.then(data => {
const p = data.packages.find(x => x.id === id);
if (!p) return;
document.getElementById('packageId').value = p.id;
document.getElementById('pkgName').value = p.name;
document.getElementById('pkgDuration').value = String(p.durationMinutes);
document.getElementById('pkgPrice').value = p.price;
document.getElementById('pkgCurrency').value = p.currency;
document.getElementById('pkgActive').checked = p.active;
document.getElementById('packageSubmitBtn').textContent = 'Paket speichern';
document.getElementById('packageCancelBtn').style.display = 'inline-block';
})
.catch(e => console.error(e));
}
function cancelPackageEdit() {
document.getElementById('packageForm').reset();
document.getElementById('packageId').value = '';
document.getElementById('pkgActive').checked = true;
document.getElementById('packageSubmitBtn').textContent = 'Paket hinzufügen';
document.getElementById('packageCancelBtn').style.display = 'none';
}
async function deletePackage(id) {
if (!confirm('Paket wirklich löschen?')) return;
try {
const r = await fetch('/admin/packages/' + encodeURIComponent(id), {
method: 'DELETE'
});
const data = await r.json();
if (data.success) {
loadPackages();
}
} catch (e) {
console.error('Failed to delete package', e);
}
}
checkAuth();
</script>
</body>
</html>

721
public/index.html Normal file
View File

@@ -0,0 +1,721 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Obi-WLANKenobi - Guest Portal</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Orbitron', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000000;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
overflow-x: hidden;
overflow-y: auto;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at top, rgba(13, 110, 253, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at bottom, rgba(13, 110, 253, 0.1) 0%, transparent 50%);
pointer-events: none;
}
.stars {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.star {
position: absolute;
background: white;
border-radius: 50%;
animation: twinkle 3s infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.container {
background: rgba(10, 25, 47, 0.95);
border: 2px solid rgba(13, 110, 253, 0.5);
border-radius: 20px;
box-shadow:
0 0 60px rgba(13, 110, 253, 0.4),
inset 0 0 60px rgba(13, 110, 253, 0.1);
padding: 50px;
max-width: 550px;
width: 100%;
text-align: center;
animation: slideUp 0.5s ease-out;
position: relative;
z-index: 10;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
width: 120px;
height: 120px;
margin: 0 auto 20px;
background: radial-gradient(circle, rgba(13, 110, 253, 0.8) 0%, rgba(13, 110, 253, 0.3) 70%);
border: 3px solid #0d6efd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 60px;
box-shadow: 0 0 40px rgba(13, 110, 253, 0.8);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 40px rgba(13, 110, 253, 0.8);
transform: scale(1);
}
50% {
box-shadow: 0 0 60px rgba(13, 110, 253, 1);
transform: scale(1.02);
}
}
h1 {
color: #0d6efd;
font-size: 36px;
margin-bottom: 10px;
font-weight: 900;
text-shadow: 0 0 20px rgba(13, 110, 253, 0.8);
letter-spacing: 2px;
}
.subtitle {
color: #6ea8fe;
font-size: 16px;
margin-bottom: 15px;
line-height: 1.6;
font-style: italic;
}
.quote {
color: #a3cfff;
font-size: 14px;
margin-bottom: 40px;
font-style: italic;
opacity: 0.9;
}
.info-box {
background: rgba(13, 110, 253, 0.1);
border: 1px solid rgba(13, 110, 253, 0.3);
border-radius: 12px;
padding: 25px;
margin-bottom: 30px;
text-align: left;
box-shadow: inset 0 0 20px rgba(13, 110, 253, 0.1);
}
.info-item {
display: flex;
align-items: center;
margin-bottom: 15px;
font-size: 14px;
color: #a3cfff;
font-weight: 500;
}
.info-item:last-child {
margin-bottom: 0;
}
.info-icon {
width: 24px;
height: 24px;
margin-right: 15px;
flex-shrink: 0;
color: #0d6efd;
filter: drop-shadow(0 0 5px rgba(13, 110, 253, 0.8));
}
.code-input {
width: 100%;
padding: 16px 20px;
margin-bottom: 20px;
background: rgba(13, 110, 253, 0.1);
border: 2px solid rgba(13, 110, 253, 0.5);
border-radius: 12px;
color: #fff;
font-family: 'Orbitron', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 3px;
text-align: center;
text-transform: uppercase;
}
.code-input::placeholder {
color: rgba(163, 207, 255, 0.5);
}
.code-input:focus {
outline: none;
border-color: #0d6efd;
box-shadow: 0 0 20px rgba(13, 110, 253, 0.4);
}
.code-label {
color: #6ea8fe;
font-size: 14px;
margin-bottom: 10px;
display: block;
}
.connect-btn {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
color: white;
border: 2px solid #0d6efd;
padding: 20px 40px;
font-size: 20px;
font-weight: 900;
border-radius: 12px;
cursor: pointer;
width: 100%;
transition: all 0.3s ease;
box-shadow:
0 4px 15px rgba(13, 110, 253, 0.6),
0 0 30px rgba(13, 110, 253, 0.4);
text-transform: uppercase;
letter-spacing: 2px;
font-family: 'Orbitron', sans-serif;
}
.connect-btn:hover {
transform: translateY(-2px);
box-shadow:
0 6px 25px rgba(13, 110, 253, 0.8),
0 0 50px rgba(13, 110, 253, 0.6);
background: linear-gradient(135deg, #0a58ca 0%, #084298 100%);
}
.connect-btn:active {
transform: translateY(0);
}
.connect-btn:disabled {
background: rgba(13, 110, 253, 0.3);
border-color: rgba(13, 110, 253, 0.3);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.spinner {
display: none;
width: 24px;
height: 24px;
border: 3px solid rgba(13, 110, 253, 0.3);
border-top-color: #0d6efd;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
box-shadow: 0 0 10px rgba(13, 110, 253, 0.5);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.message {
margin-top: 20px;
padding: 15px;
border-radius: 8px;
font-size: 14px;
display: none;
font-weight: 600;
}
.message.success {
background: rgba(13, 110, 253, 0.2);
color: #6ea8fe;
border: 1px solid rgba(13, 110, 253, 0.5);
box-shadow: 0 0 20px rgba(13, 110, 253, 0.3);
}
.message.error {
background: rgba(220, 53, 69, 0.2);
color: #ea868f;
border: 1px solid rgba(220, 53, 69, 0.5);
box-shadow: 0 0 20px rgba(220, 53, 69, 0.3);
}
.terms {
margin-top: 30px;
font-size: 11px;
color: rgba(163, 207, 255, 0.6);
line-height: 1.6;
}
.terms a {
color: #0d6efd;
text-decoration: none;
text-shadow: 0 0 5px rgba(13, 110, 253, 0.5);
}
.terms a:hover {
text-decoration: underline;
text-shadow: 0 0 10px rgba(13, 110, 253, 0.8);
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
border-bottom: 2px solid rgba(13, 110, 253, 0.3);
}
.tab {
flex: 1;
padding: 12px 16px;
background: transparent;
border: none;
color: #6ea8fe;
font-family: 'Orbitron', sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab:hover {
color: #a3cfff;
}
.tab.active {
color: #0d6efd;
border-bottom-color: #0d6efd;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.package-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 50vh;
overflow-y: auto;
}
.package-row {
background: rgba(13, 110, 253, 0.1);
border: 2px solid rgba(13, 110, 253, 0.4);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.package-row:hover {
border-color: rgba(13, 110, 253, 0.6);
}
.package-row.expanded {
border-color: #0d6efd;
box-shadow: 0 0 15px rgba(13, 110, 253, 0.3);
}
.package-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
cursor: pointer;
text-align: left;
user-select: none;
}
.package-header:focus {
outline: none;
}
.package-name {
color: #0d6efd;
font-size: 16px;
font-weight: 700;
}
.package-price {
color: #6ea8fe;
font-size: 16px;
font-weight: 700;
}
.package-chevron {
margin-left: 10px;
color: #6ea8fe;
font-size: 14px;
transition: transform 0.2s;
}
.package-row.expanded .package-chevron {
transform: rotate(180deg);
}
.package-paypal {
display: none;
padding: 0 18px 18px;
border-top: 1px solid rgba(13, 110, 253, 0.3);
}
.package-row.expanded .package-paypal {
display: block;
}
.package-paypal > div {
min-width: 150px;
margin-top: 14px;
}
@media (max-width: 600px) {
.container {
padding: 30px 20px;
}
h1 {
font-size: 28px;
}
.logo {
width: 100px;
height: 100px;
font-size: 50px;
}
.connect-btn {
padding: 18px 30px;
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="stars" id="stars"></div>
<div class="container">
<div class="logo">⚔️</div>
<h1>OBI-WLANKENOBI</h1>
<p class="subtitle">A Guest Portal From A More Civilized Age</p>
<p class="quote">"Hello there! The Force is strong with this connection."</p>
<div class="info-box">
<div class="info-item">
<svg class="info-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>Faster than the Millennium Falcon</span>
</div>
<div class="info-item">
<svg class="info-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span>Protected by Jedi encryption</span>
</div>
<div class="info-item">
<svg class="info-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>24 parsecs of connectivity</span>
</div>
</div>
<div class="tabs">
<button type="button" class="tab active" data-tab="paypal">Mit PayPal kaufen</button>
<button type="button" class="tab" data-tab="code">Ich habe einen Code</button>
</div>
<div id="paypalPanel" class="tab-panel active">
<div id="packagesContainer"></div>
</div>
<div id="codePanel" class="tab-panel">
<label class="code-label" for="wifiCode">Zugangscode eingeben</label>
<input type="text" id="wifiCode" class="code-input" placeholder="XXXXXXXX" maxlength="8" autocomplete="off">
<button class="connect-btn" id="connectBtn" onclick="connectToWiFi()">
<span id="btnText">Use The Force</span>
<div class="spinner" id="spinner"></div>
</button>
</div>
<div class="message" id="message"></div>
<div class="terms">
By connecting, you agree this is the way. May the WiFi be with you.
</div>
</div>
<script>
// Create starfield
function createStars() {
const starsContainer = document.getElementById('stars');
const starCount = 200;
for (let i = 0; i < starCount; i++) {
const star = document.createElement('div');
star.className = 'star';
const size = Math.random() * 2 + 1;
star.style.width = size + 'px';
star.style.height = size + 'px';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.animationDelay = Math.random() * 3 + 's';
star.style.animationDuration = (Math.random() * 3 + 2) + 's';
starsContainer.appendChild(star);
}
}
createStars();
// PayPal ist jetzt Standard-Tab Pakete beim Start laden
loadPackagesAndPayPal();
// Tabs
document.querySelectorAll('.tab').forEach((t) => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((x) => x.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach((x) => x.classList.remove('active'));
t.classList.add('active');
const panel = t.dataset.tab === 'code' ? 'codePanel' : 'paypalPanel';
document.getElementById(panel).classList.add('active');
if (t.dataset.tab === 'paypal') loadPackagesAndPayPal();
});
});
let paypalLoaded = false;
function togglePackage(row) {
const wasExpanded = row.classList.contains('expanded');
document.querySelectorAll('.package-row').forEach((r) => r.classList.remove('expanded'));
document.querySelectorAll('.package-header[aria-expanded]').forEach((h) => h.setAttribute('aria-expanded', 'false'));
if (!wasExpanded) {
row.classList.add('expanded');
row.querySelector('.package-header').setAttribute('aria-expanded', 'true');
}
}
async function loadPackagesAndPayPal() {
const container = document.getElementById('packagesContainer');
container.innerHTML = '<p style="color:#6ea8fe">Lade Pakete...</p>';
try {
const [pkgsRes, configRes] = await Promise.all([
fetch('/api/packages'),
fetch('/api/paypal/config')
]);
const { packages } = await pkgsRes.json();
const { clientId } = await configRes.json();
if (!packages || packages.length === 0) {
container.innerHTML = '<p style="color:#6ea8fe">Derzeit keine Pakete verfügbar.</p>';
return;
}
if (!clientId) {
container.innerHTML = '<p style="color:#ea868f">PayPal ist nicht konfiguriert.</p>';
return;
}
container.innerHTML = '';
container.className = 'package-list';
packages.forEach((pkg) => {
const row = document.createElement('div');
row.className = 'package-row';
row.dataset.packageId = pkg.id;
row.innerHTML = `
<div class="package-header" role="button" tabindex="0" aria-expanded="false">
<span class="package-name">${pkg.name}</span>
<span>
<span class="package-price">${pkg.price.toFixed(2)} ${pkg.currency}</span>
<span class="package-chevron">▼</span>
</span>
</div>
<div class="package-paypal" id="paypal-btn-${pkg.id}"></div>
`;
const header = row.querySelector('.package-header');
header.addEventListener('click', () => togglePackage(row));
header.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePackage(row); } });
container.appendChild(row);
});
if (!paypalLoaded) {
const script = document.createElement('script');
script.src = `https://www.paypal.com/sdk/js?client-id=${clientId}&currency=EUR`;
script.async = true;
script.onload = () => renderPayPalButtons(packages);
document.head.appendChild(script);
paypalLoaded = true;
} else {
renderPayPalButtons(packages);
}
} catch (e) {
container.innerHTML = '<p style="color:#ea868f">Fehler beim Laden.</p>';
}
}
function renderPayPalButtons(packages) {
if (typeof paypal === 'undefined') return;
packages.forEach((pkg) => {
const el = document.getElementById('paypal-btn-' + pkg.id);
if (!el || el.hasChildNodes()) return;
paypal.Buttons({
createOrder: async () => {
const r = await fetch('/api/paypal/create-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packageId: pkg.id })
});
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Order failed');
return data.orderId;
},
onApprove: async (data) => {
const message = document.getElementById('message');
try {
const r = await fetch('/api/paypal/capture-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: data.orderID })
});
const result = await r.json();
if (result.success) {
message.className = 'message success';
message.textContent = 'Bezahlung erfolgreich! Du wirst verbunden...';
message.style.display = 'block';
setTimeout(() => {
window.location.href = 'http://captive.apple.com/hotspot-detect.html';
}, 1500);
setTimeout(() => {
window.location.href = result.redirectUrl || 'https://google.com';
}, 3500);
} else {
throw new Error(result.error);
}
} catch (err) {
message.className = 'message error';
message.textContent = 'Fehler: ' + (err.message || 'Bezahlung fehlgeschlagen.');
message.style.display = 'block';
}
},
onError: (err) => {
const message = document.getElementById('message');
message.className = 'message error';
message.textContent = 'PayPal Fehler: ' + (err.message || 'Unbekannt');
message.style.display = 'block';
}
}).render('#paypal-btn-' + pkg.id);
});
}
async function connectToWiFi() {
const btn = document.getElementById('connectBtn');
const btnText = document.getElementById('btnText');
const spinner = document.getElementById('spinner');
const message = document.getElementById('message');
const codeInput = document.getElementById('wifiCode');
const code = (codeInput && codeInput.value || '').trim();
if (!code) {
message.className = 'message error';
message.textContent = 'Please enter your access code.';
message.style.display = 'block';
return;
}
// Disable button and show loading
btn.disabled = true;
btnText.style.display = 'none';
spinner.style.display = 'block';
message.style.display = 'none';
try {
const response = await fetch('/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
const data = await response.json();
if (data.success) {
// Show success message
message.className = 'message success';
message.textContent = '✨ You are now connected to the Force! Redirecting...';
message.style.display = 'block';
// For iOS/macOS: redirect to Apple's captive portal detection URL
// This triggers the CNA (Captive Network Assistant) to close
setTimeout(() => {
// Try Apple's captive portal URL first (closes iOS popup)
window.location.href = 'http://captive.apple.com/hotspot-detect.html';
}, 1500);
// Fallback: if still here after 3s, try the redirect URL
setTimeout(() => {
window.location.href = data.redirectUrl || 'https://www.google.com';
}, 3500);
} else {
throw new Error(data.message);
}
} catch (error) {
// Show error message
message.className = 'message error';
message.textContent = '⚠️ ' + (error.message || 'The Force is not strong with this one. Please try again.');
message.style.display = 'block';
// Re-enable button
btn.disabled = false;
btnText.style.display = 'block';
spinner.style.display = 'none';
}
}
</script>
</body>
</html>

601
server.js Normal file
View File

@@ -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/<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`);
});

20
unifi-portal.service Normal file
View File

@@ -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