mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
Compare commits
28 Commits
1f534b45c3
...
1e302e5c2c
Author | SHA1 | Date | |
---|---|---|---|
|
1e302e5c2c | ||
|
d6e11f406c | ||
|
ef1e7a780e | ||
|
9084d2ef3e | ||
|
492e71fcc3 | ||
|
89a1421efb | ||
|
a83be957df | ||
|
74e824f834 | ||
|
dacd6a9a5c | ||
|
b81efc3c87 | ||
|
404d35b437 | ||
|
fbff084818 | ||
|
a8288d785d | ||
|
d14e940563 | ||
|
14b3b408ea | ||
|
7dd8218867 | ||
|
c9e85d5172 | ||
|
ce6c92edf4 | ||
|
486f384409 | ||
|
04a6508554 | ||
|
f799a8575e | ||
|
ee6b0d6109 | ||
|
b8a7ca808b | ||
|
562a314fc8 | ||
|
4dddbe9b70 | ||
|
0b22b2277e | ||
|
7017308db9 | ||
|
c639d7ce22 |
160
README.md
160
README.md
@@ -3,7 +3,7 @@
|
||||
[ ](https://hub.docker.com/r/etiennecollin/unifi-voucher-manager)
|
||||
[ ](https://github.com/etiennecollin/unifi-voucher-manager)
|
||||
|
||||
A modern, touch-friendly web application for managing WiFi vouchers on UniFi controllers.
|
||||
UVM is a modern, touch-friendly web application for managing WiFi vouchers on UniFi controllers.
|
||||
Perfect for businesses, cafes, hotels, and home networks that need to provide guest WiFi access.
|
||||
|
||||

|
||||
@@ -12,14 +12,17 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🎫 Voucher Management & WiFi QR Code](#-voucher-management--wifi-qr-code)
|
||||
- [Kiosk Display](#kiosk-display)
|
||||
- [🎨 Modern Interface](#-modern-interface)
|
||||
- [🔧 Technical Features](#-technical-features)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [Using Docker Compose (Recommended)](#using-docker-compose-recommended)
|
||||
- [Without Docker](#without-docker)
|
||||
- [⚙️ Configuration](#-configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Getting UniFi API Credentials](#getting-unifi-api-credentials)
|
||||
- [Rolling Vouchers and Kiosk Page](#rolling-vouchers-and-kiosk-page)
|
||||
- [How Rolling Vouchers Work](#how-rolling-vouchers-work)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [🐛 Troubleshooting](#-troubleshooting)
|
||||
- [Common Issues](#common-issues)
|
||||
- [Getting Help](#getting-help)
|
||||
@@ -37,18 +40,27 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
- Guest count limits
|
||||
- Data usage limits
|
||||
- Upload/download speed limits
|
||||
- **View All Vouchers** - Browse and search existing vouchers by name
|
||||
- **Search Vouchers** - Search vouchers by name
|
||||
- **Bulk Operations** - Select and delete multiple vouchers
|
||||
- **Browse Vouchers** - Browse and search existing vouchers by name
|
||||
- **Bulk Operations** - Select and delete multiple vouchers at once
|
||||
- **Print Vouchers** - Print vouchers in either list or grid format; thermal printers friendly
|
||||
- **Auto-cleanup** - Remove expired vouchers with a single click
|
||||
- **QR Code** - Easily connect guests to your network
|
||||
- **Rolling Vouchers** - Automatically generate a voucher for the next guest when the current one gets used
|
||||
|
||||
### Kiosk Display
|
||||
|
||||
The kiosk page (`/kiosk`) provides a guest-friendly interface displaying:
|
||||
|
||||
- **QR Code**: For easy network connection (if configured in [Environment Variables](#environment-variables))
|
||||
- **Current Voucher**: The active rolling voucher code
|
||||
- **Real-time Updates**: Automatically refreshes when the rolling voucher changes
|
||||
|
||||
### 🎨 Modern Interface
|
||||
|
||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop.
|
||||
- **Dark/Light Mode** – Follows system preference, with manual override.
|
||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop
|
||||
- **Dark/Light Mode** – Follows system preference, with manual override
|
||||
- **Responsive Design** - Works seamlessly across all screen sizes
|
||||
- **Smooth Animations** – Semantic transitions for polished UX.
|
||||
- **Smooth Animations** – Semantic transitions for polished UX
|
||||
- **Real-time Notifications** - Instant feedback for all operations
|
||||
|
||||
### 🔧 Technical Features
|
||||
@@ -109,30 +121,6 @@ Perfect for businesses, cafes, hotels, and home networks that need to provide gu
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Make sure to configure the required variables. The optional variables generally have default values that you should not have to change.
|
||||
|
||||
To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and `WIFI_PASSWORD` variables.
|
||||
|
||||
| Variable | Required? | Description | Example | Type |
|
||||
| ------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol (`http://` or `https://`). | `https://unifi.example.com` or `https://192.168.8.1:443` | `string` |
|
||||
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` | `string` |
|
||||
| `UNIFI_HAS_VALID_CERT` | Optional | Whether your UniFi controller uses a valid SSL certificate. This should normally be set to `true`, especially if you access the controller through a reverse proxy or another setup that provides trusted certificates (e.g., Let's Encrypt). **If you connect directly to the controller’s IP address (which usually serves a self-signed certificate), you may need to set this to `false`.** | `true` (default) | `bool` |
|
||||
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) | `string` |
|
||||
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) | `IPv4` |
|
||||
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) | `u16` |
|
||||
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) | `URL` |
|
||||
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) | `IPv4` |
|
||||
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) | `u16` |
|
||||
| `BACKEND_LOG_LEVEL` | Optional | Log level of the Rust backend. | `info`(default) | `trace\|debug\|info\|warn\|error` |
|
||||
| `TIMEZONE` | Optional | [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) used to format dates and time. | `UTC` (default) | [`timezone identifier`](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) |
|
||||
| `WIFI_SSID` | Optional | WiFi SSID used for the QR code. (required for QR code to be generated) | `My WiFi SSID` | `string` |
|
||||
| `WIFI_PASSWORD` | Optional | WiFi password used for the QR code. If the WiFi network does not have a password, set to an empty string `""`. (required for QR code to be generated) | `My WiFi Password` | `string` |
|
||||
| `WIFI_TYPE` | Optional | WiFi security type used. Defaults to `WPA` if a password is provided and `nopass` otherwise. | `WPA` | `WPA\|WEP\|nopass` |
|
||||
| `WIFI_HIDDEN` | Optional | Whether the WiFi SSID is hidden or broadcasted. | `false` (default) | `bool` |
|
||||
|
||||
### Getting UniFi API Credentials
|
||||
|
||||
1. **Access your UniFi Controller**
|
||||
@@ -140,6 +128,108 @@ To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and
|
||||
3. **Create a new API key** by giving it a name and an expiration.
|
||||
4. **Find your Site ID** in the controller URL or on [unifi.ui.com](https://unifi.ui.com)
|
||||
|
||||
### Rolling Vouchers and Kiosk Page
|
||||
|
||||
Rolling vouchers provide a seamless way to automatically generate guest network access codes. When one voucher is used, a new one is automatically created for the next guest.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Setup Required**
|
||||
>
|
||||
> For rolling vouchers to work properly, you **must** configure your UniFi Hotspot:
|
||||
>
|
||||
> 1. Go to your UniFi Controller -> Insights -> Hotspot
|
||||
> 2. Set the **Success Landing Page** to: `https://your-uvm-domain.com/welcome`, the `/welcome` page of UVM
|
||||
>
|
||||
> Without this configuration, vouchers **will not** automatically roll when guests connect.
|
||||
|
||||
> [!CAUTION]
|
||||
> To restrict UVM access to the guest subnetwork users while still allowing access to `/welcome` page, set the `GUEST_SUBNETWORK` environment variable. This makes sure guests do not have access to other UVM pages, such as the voucher management interface (the root `/` page).
|
||||
>
|
||||
> Without this configuration, guests **will be able** to access the voucher management interface of UVM. This means they will be able to both create and delete vouchers by themselves.
|
||||
|
||||
#### How Rolling Vouchers Work
|
||||
|
||||
1. **Initial Setup**: Rolling vouchers are generated automatically when needed
|
||||
2. **Guest Connection**: When a guest connects to your network, they're redirected to the `/welcome` page
|
||||
3. **Automatic Rolling**: The welcome page triggers the creation of a new voucher for the next guest
|
||||
- Rolling vouchers are created with special naming conventions to distinguish them from manually created vouchers, making them easy to identify in your voucher management interface
|
||||
4. **IP-Based Uniqueness**: Each IP address can only generate one voucher per session (prevents abuse from page reloads)
|
||||
5. **Daily Maintenance**: To prevent clutter, expired rolling vouchers are automatically deleted at midnight (based on your configured `TIMEZONE` in [Environment Variables](#environment-variables))
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Make sure to configure the required variables. The optional variables generally have default values that you should not have to change.
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> - To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and `WIFI_PASSWORD` variables.
|
||||
> - For proper timezone, make sure to set the `TIMEZONE` variable.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Make sure to expand this section and read what the environment variables are doing. Some variables are **required**, they are placed at the top of the list.
|
||||
|
||||
- **`UNIFI_CONTROLLER_URL`: `string`** (_Required_)
|
||||
- **Description**: URL to your UniFi controller with protocol (`http://` or `https://`).
|
||||
- **Example**: `https://unifi.example.com` or `https://192.168.8.1:443`
|
||||
- **`UNIFI_API_KEY`: `string`** (_Required_)
|
||||
- **Description**: API Key for your UniFi controller.
|
||||
- **Example**: `abc123...`
|
||||
|
||||
> [!WARNING]
|
||||
> Improperly setting the `UNIFI_HAS_VALID_CERT` variable **will** prevent UVM from communicating with the UniFi controller.
|
||||
|
||||
- **`UNIFI_HAS_VALID_CERT`: `bool`** (_Optional_)
|
||||
- **Description**: Whether your UniFi controller uses a valid SSL certificate. This should normally be set to `true`, especially if you access the controller through a reverse proxy or another setup that provides trusted certificates (e.g., Let's Encrypt). **If you connect directly to the controller’s IP address (which usually serves a self-signed certificate), you may need to set this to `false`.**
|
||||
- **Example**: `true` (default)
|
||||
- **`UNIFI_SITE_ID`: `string`** (_Optional_)
|
||||
- **Description**: Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site.
|
||||
- **Example**: `default` (default)
|
||||
|
||||
> [!CAUTION]
|
||||
> To restrict UVM access to the guest subnetwork users while still allowing access to `/welcome` page, set the `GUEST_SUBNETWORK` variable. This makes sure guests do not have access to other UVM pages, such as the voucher management interface (the root `/` page).
|
||||
>
|
||||
> Without this configuration, guests **will be able** to access the voucher management interface of UVM. This means they will be able to both create and delete vouchers by themselves.
|
||||
|
||||
- **`GUEST_SUBNETWORK`: `IPv4 CIDR`** (_Optional_)
|
||||
- **Description**: Restrict guest subnetwork access to UVM while still permitting access to the `/welcome` page, which users are redirected to from the UniFi captive portal. For more details, see [Rolling Vouchers and Kiosk Page](#rolling-vouchers-and-kiosk-page).
|
||||
- **Example**: `10.0.5.0/24`
|
||||
- **`FRONTEND_BIND_HOST`: `IPv4`** (_Optional_)
|
||||
- **Description**: Address on which the frontend server binds.
|
||||
- **Example**: `0.0.0.0` (default)
|
||||
- **`FRONTEND_BIND_PORT`: `u16`** (_Optional_)
|
||||
- **Description**: Port on which the frontend server binds.
|
||||
- **Example**: `3000` (default)
|
||||
- **`FRONTEND_TO_BACKEND_URL`: `URL`** (_Optional_)
|
||||
- **Description**: URL where the frontend will make its API requests to the backend.
|
||||
- **Example**: `http://127.0.0.1` (default)
|
||||
- **`BACKEND_BIND_HOST`: `IPv4`** (_Optional_)
|
||||
- **Description**: Address on which the server binds.
|
||||
- **Example**: `127.0.0.1` (default)
|
||||
- **`BACKEND_BIND_PORT`: `u16`** (_Optional_)
|
||||
- **Description**: Port on which the backend server binds.
|
||||
- **Example**: `8080` (default)
|
||||
- **`BACKEND_LOG_LEVEL`: `trace|debug|info|warn|error`** (_Optional_)
|
||||
- **Description**: Log level of the Rust backend.
|
||||
- **Example**: `info`(default)
|
||||
- **`TIMEZONE`: [`timezone identifier`](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)** (_Optional_)
|
||||
- **Description**: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) used to format dates and time.
|
||||
- **Example**: `UTC` (default)
|
||||
- **`ROLLING_VOUCHER_DURATION_MINUTES`: `minutes`** (_Optional_)
|
||||
- **Description**: Number of minutes a rolling voucher will be valid for once activated.
|
||||
- **Example**: `480` (default)
|
||||
- **`WIFI_SSID`: `string`** (_Optional_)
|
||||
- **Description**: WiFi SSID used for the QR code. (required for QR code to be generated)
|
||||
- **Example**: `My WiFi SSID`
|
||||
- **`WIFI_PASSWORD`: `string`** (_Optional_)
|
||||
- **Description**: WiFi password used for the QR code. If the WiFi network does not have a password, set to an empty string `""`. (required for QR code to be generated)
|
||||
- **Example**: `My WiFi Password`
|
||||
- **`WIFI_TYPE`: `WPA|WEP|nopass`** (_Optional_)
|
||||
- **Description**: WiFi security type used. Defaults to `WPA` if a password is provided and `nopass` otherwise.
|
||||
- **Example**: `WPA`
|
||||
- **`WIFI_HIDDEN`: `bool`** (_Optional_)
|
||||
- **Description**: Whether the WiFi SSID is hidden or broadcasted.
|
||||
- **Example**: `false` (default)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
@@ -155,9 +245,9 @@ To configure the WiFi QR code, you are required to configure the `WIFI_SSID` and
|
||||
- Check all environment variables are set
|
||||
- Verify Docker container has network access to UniFi controller
|
||||
- Check logs: `docker logs unifi-voucher-manager`
|
||||
- **The WiFi QR code button is seems disabled**
|
||||
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code.
|
||||
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab).
|
||||
- **The WiFi QR code button is disabled**
|
||||
- Check the [Environment Variables](#environment-variables) section and make sure you configured the variables required for the WiFi QR code
|
||||
- Check the browser console for variable configuration errors (generally by hitting `F12` and going to the 'console' tab)
|
||||
|
||||
### Getting Help
|
||||
|
||||
|
5
backend/Cargo.lock
generated
5
backend/Cargo.lock
generated
@@ -115,6 +115,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"dotenvy",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -929,9 +930,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
|
@@ -8,6 +8,7 @@ axum = "0.8.4"
|
||||
chrono = { version = "0.4.41" }
|
||||
chrono-tz = "0.10.4"
|
||||
dotenvy = { version = "0.15.7", optional = true }
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { version = "0.12.22", features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
|
103
backend/src/environment.rs
Normal file
103
backend/src/environment.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::{env, sync::OnceLock};
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use tracing::{error, info};
|
||||
|
||||
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
|
||||
const DEFAULT_UNIFI_SITE_ID: &str = "default";
|
||||
const DEEFAULT_ROLLING_VOUCHER_DURATION_MINUTES: u64 = 480;
|
||||
|
||||
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Environment {
|
||||
pub unifi_controller_url: String,
|
||||
pub unifi_site_id: String,
|
||||
pub unifi_api_key: String,
|
||||
pub backend_bind_host: String,
|
||||
pub backend_bind_port: u16,
|
||||
pub rolling_voucher_duration_minutes: u64,
|
||||
pub unifi_has_valid_cert: bool,
|
||||
pub timezone: Tz,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
#[cfg(feature = "dotenv")]
|
||||
dotenvy::dotenv().map_err(|e| format!("Failed to load .env file: {e}"))?;
|
||||
|
||||
let unifi_controller_url: String =
|
||||
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
|
||||
|
||||
if !unifi_controller_url.starts_with("http://")
|
||||
&& !unifi_controller_url.starts_with("https://")
|
||||
{
|
||||
return Err("UNIFI_CONTROLLER_URL must start with http:// or https://".to_string());
|
||||
}
|
||||
|
||||
let unifi_api_key: String =
|
||||
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
|
||||
let unifi_site_id: String =
|
||||
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
|
||||
|
||||
let backend_bind_host: String =
|
||||
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
|
||||
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
|
||||
Ok(port_str) => port_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
|
||||
Err(_) => DEFAULT_BACKEND_BIND_PORT,
|
||||
};
|
||||
|
||||
let rolling_voucher_duration_minutes = match env::var("ROLLING_VOUCHER_DURATION_MINUTES") {
|
||||
Ok(val) => val
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid ROLLING_VOUCHER_DURATION_MINUTES: {e}"))?,
|
||||
Err(_) => DEEFAULT_ROLLING_VOUCHER_DURATION_MINUTES,
|
||||
};
|
||||
|
||||
let unifi_has_valid_cert: bool = match env::var("UNIFI_HAS_VALID_CERT") {
|
||||
Ok(val) => {
|
||||
Self::parse_bool(&val).map_err(|e| format!("Invalid UNIFI_HAS_VALID_CERT: {e}"))?
|
||||
}
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let timezone: Tz = match env::var("TIMEZONE") {
|
||||
Ok(s) => match s.parse() {
|
||||
Ok(tz) => {
|
||||
info!("Using timezone: {}", s);
|
||||
tz
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Using UTC, could not parse timezone: {}", s);
|
||||
Tz::UTC
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
info!("TIMEZONE environment variable not set, defaulting to UTC");
|
||||
Tz::UTC
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
unifi_controller_url,
|
||||
unifi_site_id,
|
||||
unifi_api_key,
|
||||
backend_bind_host,
|
||||
backend_bind_port,
|
||||
rolling_voucher_duration_minutes,
|
||||
unifi_has_valid_cert,
|
||||
timezone,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_bool(s: &str) -> Result<bool, String> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => Err(format!("Boolean value must be true or false, found: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,11 @@
|
||||
use crate::{models::*, unifi_api::*};
|
||||
use axum::{extract::Query, http::StatusCode, response::Json};
|
||||
use tracing::{debug, error};
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::{models::*, unifi_api::UNIFI_API};
|
||||
|
||||
pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusCode> {
|
||||
debug!("Received request to get vouchers");
|
||||
@@ -14,7 +19,20 @@ pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusC
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher_handler() -> Result<Json<Option<Voucher>>, StatusCode> {
|
||||
pub async fn get_rolling_voucher_handler() -> Result<Json<Voucher>, StatusCode> {
|
||||
debug!("Received request to get rolling voucher");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.get_rolling_voucher().await {
|
||||
Ok(Some(voucher)) => Ok(Json(voucher)),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(e) => {
|
||||
error!("Failed to get rolling voucher: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher_handler() -> Result<Json<Voucher>, StatusCode> {
|
||||
debug!("Received request to get newest voucher");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.get_newest_voucher().await {
|
||||
@@ -54,6 +72,38 @@ pub async fn create_voucher_handler(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_rolling_voucher_handler(
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Voucher>, StatusCode> {
|
||||
debug!("Received request to create voucher");
|
||||
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(ip) = forwarded.to_str() {
|
||||
debug!("Client IP from x-forwarded-for: {}", ip);
|
||||
|
||||
// Check if user already rotated the rolling voucher
|
||||
if client.check_rolling_voucher_ip(ip).await? {
|
||||
info!("Rolling voucher already rotated for IP: {}", ip);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// Voucher rotation allowed, create a new rolling voucher
|
||||
match client.create_rolling_voucher(ip).await {
|
||||
Ok(response) => return Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to create rolling voucher: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error!("Invalid x-forwarded-for header");
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub async fn delete_selected_handler(
|
||||
Query(params): Query<DeleteRequest>,
|
||||
) -> Result<Json<DeleteResponse>, StatusCode> {
|
||||
@@ -81,6 +131,18 @@ pub async fn delete_expired_handler() -> Result<Json<DeleteResponse>, StatusCode
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_expired_rolling_handler() -> Result<Json<DeleteResponse>, StatusCode> {
|
||||
debug!("Received request to delete expired rolling voucher");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.delete_expired_rolling_vouchers().await {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to delete expired rolling voucher: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn health_check_handler() -> Result<Json<HealthCheckResponse>, StatusCode> {
|
||||
debug!("Received health check request");
|
||||
let response = HealthCheckResponse {
|
||||
|
@@ -1,97 +1,5 @@
|
||||
use std::{env, sync::OnceLock};
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub mod environment;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod unifi_api;
|
||||
|
||||
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
|
||||
const DEFAULT_UNIFI_SITE_ID: &str = "default";
|
||||
|
||||
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Environment {
|
||||
pub unifi_controller_url: String,
|
||||
pub unifi_site_id: String,
|
||||
pub unifi_api_key: String,
|
||||
pub backend_bind_host: String,
|
||||
pub backend_bind_port: u16,
|
||||
pub unifi_has_valid_cert: bool,
|
||||
pub timezone: Tz,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
#[cfg(feature = "dotenv")]
|
||||
dotenvy::dotenv().map_err(|e| format!("Failed to load .env file: {e}"))?;
|
||||
|
||||
let unifi_controller_url: String =
|
||||
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
|
||||
|
||||
if !unifi_controller_url.starts_with("http://")
|
||||
&& !unifi_controller_url.starts_with("https://")
|
||||
{
|
||||
return Err("UNIFI_CONTROLLER_URL must start with http:// or https://".to_string());
|
||||
}
|
||||
|
||||
let unifi_api_key: String =
|
||||
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
|
||||
let unifi_site_id: String =
|
||||
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
|
||||
|
||||
let backend_bind_host: String =
|
||||
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
|
||||
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
|
||||
Ok(port_str) => port_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
|
||||
Err(_) => DEFAULT_BACKEND_BIND_PORT,
|
||||
};
|
||||
|
||||
let unifi_has_valid_cert: bool = match env::var("UNIFI_HAS_VALID_CERT") {
|
||||
Ok(val) => {
|
||||
Self::parse_bool(&val).map_err(|e| format!("Invalid UNIFI_HAS_VALID_CERT: {e}"))?
|
||||
}
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let timezone: Tz = match env::var("TIMEZONE") {
|
||||
Ok(s) => match s.parse() {
|
||||
Ok(tz) => {
|
||||
info!("Using timezone: {}", s);
|
||||
tz
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Using UTC, could not parse timezone: {}", s);
|
||||
Tz::UTC
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
info!("TIMEZONE environment variable not set, defaulting to UTC");
|
||||
Tz::UTC
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
unifi_controller_url,
|
||||
unifi_site_id,
|
||||
unifi_api_key,
|
||||
backend_bind_host,
|
||||
backend_bind_port,
|
||||
unifi_has_valid_cert,
|
||||
timezone,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_bool(s: &str) -> Result<bool, String> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => Err(format!("Boolean value must be true or false, found: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,18 +3,22 @@ use axum::{
|
||||
http::{self, Method},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use backend::{
|
||||
ENVIRONMENT, Environment,
|
||||
handlers::*,
|
||||
unifi_api::{UNIFI_API, UnifiAPI},
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::{error, info, level_filters::LevelFilter, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{
|
||||
environment::{ENVIRONMENT, Environment},
|
||||
handlers::*,
|
||||
tasks::run_daily_purge,
|
||||
unifi_api::{UNIFI_API, UnifiAPI},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// =================================
|
||||
// Initialize tracing
|
||||
// =================================
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::builder()
|
||||
@@ -24,6 +28,9 @@ async fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
// =================================
|
||||
// Setup environment variables manager
|
||||
// =================================
|
||||
let env = match Environment::try_new() {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
@@ -34,7 +41,11 @@ async fn main() {
|
||||
ENVIRONMENT
|
||||
.set(env)
|
||||
.expect("Failed to set environment variables");
|
||||
let environment = ENVIRONMENT.get().expect("Environment not set");
|
||||
|
||||
// =================================
|
||||
// Setup UniFi Controller API connection
|
||||
// =================================
|
||||
loop {
|
||||
match UnifiAPI::try_new().await {
|
||||
Ok(api) => {
|
||||
@@ -50,6 +61,14 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// =================================
|
||||
// Start scheduled tasks
|
||||
// =================================
|
||||
tokio::spawn(run_daily_purge(environment.timezone));
|
||||
|
||||
// =================================
|
||||
// Setup Axum server
|
||||
// =================================
|
||||
let cors = CorsLayer::new()
|
||||
.allow_headers([http::header::CONTENT_TYPE])
|
||||
.allow_methods([Method::POST, Method::GET, Method::DELETE])
|
||||
@@ -61,17 +80,31 @@ async fn main() {
|
||||
.route("/api/vouchers", post(create_voucher_handler))
|
||||
.route("/api/vouchers/details", get(get_voucher_details_handler))
|
||||
.route("/api/vouchers/expired", delete(delete_expired_handler))
|
||||
.route(
|
||||
"/api/vouchers/expired/rolling",
|
||||
delete(delete_expired_rolling_handler),
|
||||
)
|
||||
.route("/api/vouchers/newest", get(get_newest_voucher_handler))
|
||||
.route("/api/vouchers/rolling", get(get_rolling_voucher_handler))
|
||||
.route(
|
||||
"/api/vouchers/rolling",
|
||||
post(create_rolling_voucher_handler),
|
||||
)
|
||||
.route("/api/vouchers/selected", delete(delete_selected_handler))
|
||||
.layer(cors);
|
||||
|
||||
let environment = ENVIRONMENT.get().expect("Environment not set");
|
||||
let bind_address = format!(
|
||||
"{}:{}",
|
||||
environment.backend_bind_host, environment.backend_bind_port
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&bind_address)
|
||||
.await
|
||||
.expect("Could not bind listener");
|
||||
|
||||
info!("Server running on http://{}", bind_address);
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Axum server should never error");
|
||||
}
|
||||
|
@@ -4,44 +4,44 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Voucher {
|
||||
id: String,
|
||||
pub id: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
name: String,
|
||||
code: String,
|
||||
pub name: String,
|
||||
pub code: String,
|
||||
#[serde(rename = "authorizedGuestLimit")]
|
||||
authorized_guest_limit: Option<u64>,
|
||||
pub authorized_guest_limit: Option<u64>,
|
||||
#[serde(rename = "authorizedGuestCount")]
|
||||
authorized_guest_count: u64,
|
||||
pub authorized_guest_count: u64,
|
||||
#[serde(rename = "activatedAt")]
|
||||
pub activated_at: Option<String>,
|
||||
#[serde(rename = "expiresAt")]
|
||||
pub expires_at: Option<String>,
|
||||
expired: bool,
|
||||
pub expired: bool,
|
||||
#[serde(rename = "timeLimitMinutes")]
|
||||
time_limit_minutes: u64,
|
||||
pub time_limit_minutes: u64,
|
||||
#[serde(rename = "dataUsageLimitMBytes")]
|
||||
data_usage_limit_mbytes: Option<u64>,
|
||||
pub data_usage_limit_mbytes: Option<u64>,
|
||||
#[serde(rename = "rxRateLimitKbps")]
|
||||
rx_rate_limit_kbps: Option<u64>,
|
||||
pub rx_rate_limit_kbps: Option<u64>,
|
||||
#[serde(rename = "txRateLimitKbps")]
|
||||
tx_rate_limit_kbps: Option<u64>,
|
||||
pub tx_rate_limit_kbps: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateVoucherRequest {
|
||||
count: u32,
|
||||
name: String,
|
||||
pub count: u32,
|
||||
pub name: String,
|
||||
#[serde(rename = "authorizedGuestLimit")]
|
||||
authorized_guest_limit: Option<u64>,
|
||||
pub authorized_guest_limit: Option<u64>,
|
||||
#[serde(rename = "timeLimitMinutes")]
|
||||
time_limit_minutes: u64,
|
||||
pub time_limit_minutes: u64,
|
||||
#[serde(rename = "dataUsageLimitMBytes")]
|
||||
data_usage_limit_mbytes: Option<u64>,
|
||||
pub data_usage_limit_mbytes: Option<u64>,
|
||||
#[serde(rename = "rxRateLimitKbps")]
|
||||
rx_rate_limit_kbps: Option<u64>,
|
||||
pub rx_rate_limit_kbps: Option<u64>,
|
||||
#[serde(rename = "txRateLimitKbps")]
|
||||
tx_rate_limit_kbps: Option<u64>,
|
||||
pub tx_rate_limit_kbps: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -80,7 +80,7 @@ pub struct DetailsRequest {
|
||||
pub struct Site {
|
||||
pub id: String,
|
||||
#[serde(rename = "internalReference")]
|
||||
internal_reference: String,
|
||||
pub internal_reference: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
|
41
backend/src/tasks.rs
Normal file
41
backend/src/tasks.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use chrono::Utc;
|
||||
use chrono_tz::Tz;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::handlers::delete_expired_rolling_handler;
|
||||
|
||||
pub async fn run_daily_purge(timezone: Tz) {
|
||||
loop {
|
||||
let now = Utc::now().with_timezone(&timezone);
|
||||
let next_midnight = now
|
||||
.date_naive()
|
||||
.succ_opt()
|
||||
.expect("Next day is not representable")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("Could not get next midnight")
|
||||
.and_local_timezone(timezone)
|
||||
.latest()
|
||||
.expect("Could not convert next midnight time to local timezone");
|
||||
|
||||
let delta = next_midnight - now;
|
||||
let duration = delta
|
||||
.to_std()
|
||||
.expect("Duration to next midnight is less than 0");
|
||||
|
||||
info!(
|
||||
"Next purge of expired rolling vouchers at midnight ({}), in {} hours and {} minutes...",
|
||||
timezone,
|
||||
delta.num_hours(),
|
||||
delta.num_minutes() % 60
|
||||
);
|
||||
|
||||
sleep(duration).await;
|
||||
|
||||
info!("Purging expired rolling vouchers...");
|
||||
match delete_expired_rolling_handler().await {
|
||||
Ok(response) => info!("Deleted {} rolling vouchers", response.vouchers_deleted),
|
||||
Err(code) => error!("Failed to delete rolling vouchers: {}", code),
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,12 +1,12 @@
|
||||
use axum::http::HeaderValue;
|
||||
use chrono::DateTime;
|
||||
use chrono_tz::Tz;
|
||||
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
|
||||
use reqwest::{Client, ClientBuilder, StatusCode};
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::{
|
||||
ENVIRONMENT, Environment,
|
||||
environment::{ENVIRONMENT, Environment},
|
||||
models::{
|
||||
CreateVoucherRequest, CreateVoucherResponse, DeleteResponse, ErrorResponse,
|
||||
GetSitesResponse, GetVouchersResponse, Voucher,
|
||||
@@ -15,6 +15,8 @@ use crate::{
|
||||
|
||||
const UNIFI_API_ROUTE: &str = "proxy/network/integration/v1/sites";
|
||||
const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
const ROLLING_VOUCHER_NAME_PREFIX: &str = "[ROLLING]";
|
||||
const FRAGMENT: &AsciiSet = &CONTROLS.add(b'[').add(b']');
|
||||
|
||||
pub static UNIFI_API: OnceLock<UnifiAPI> = OnceLock::new();
|
||||
|
||||
@@ -84,6 +86,36 @@ impl<'a> UnifiAPI<'a> {
|
||||
Ok(unifi_api)
|
||||
}
|
||||
|
||||
fn format_unifi_date(&self, rfc3339_string: &str) -> String {
|
||||
match DateTime::parse_from_rfc3339(rfc3339_string) {
|
||||
Ok(dt) => {
|
||||
let local_time = dt.with_timezone(&self.environment.timezone);
|
||||
local_time.format(DATE_TIME_FORMAT).to_string()
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Failed to parse RFC3339 date: {}", rfc3339_string);
|
||||
rfc3339_string.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_voucher(&self, voucher: &mut Voucher) {
|
||||
voucher.created_at = self.format_unifi_date(&voucher.created_at);
|
||||
if let Some(activated_at) = &mut voucher.activated_at {
|
||||
*activated_at = self.format_unifi_date(activated_at);
|
||||
}
|
||||
if let Some(expires_at) = &mut voucher.expires_at {
|
||||
*expires_at = self.format_unifi_date(expires_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_vouchers(&self, mut vouchers: Vec<Voucher>) -> Vec<Voucher> {
|
||||
vouchers.iter_mut().for_each(|voucher| {
|
||||
self.process_voucher(voucher);
|
||||
});
|
||||
vouchers
|
||||
}
|
||||
|
||||
async fn make_request<
|
||||
T: serde::ser::Serialize + Sized,
|
||||
U: serde::de::DeserializeOwned + Sized,
|
||||
@@ -159,23 +191,6 @@ impl<'a> UnifiAPI<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
fn process_voucher(&self, voucher: &mut Voucher) {
|
||||
voucher.created_at = format_unifi_date(&voucher.created_at, &self.environment.timezone);
|
||||
if let Some(activated_at) = &mut voucher.activated_at {
|
||||
*activated_at = format_unifi_date(activated_at, &self.environment.timezone);
|
||||
}
|
||||
if let Some(expires_at) = &mut voucher.expires_at {
|
||||
*expires_at = format_unifi_date(expires_at, &self.environment.timezone);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_vouchers(&self, mut vouchers: Vec<Voucher>) -> Vec<Voucher> {
|
||||
vouchers.iter_mut().for_each(|voucher| {
|
||||
self.process_voucher(voucher);
|
||||
});
|
||||
vouchers
|
||||
}
|
||||
|
||||
async fn get_default_site_id(&self) -> Result<String, StatusCode> {
|
||||
let url = format!(
|
||||
"{}?filter=or(internalReference.eq('default'),name.eq('Default'))",
|
||||
@@ -206,7 +221,24 @@ impl<'a> UnifiAPI<'a> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher(&self) -> Result<Option<Voucher>, StatusCode> {
|
||||
pub async fn get_rolling_voucher(&self) -> Result<Option<Voucher>, StatusCode> {
|
||||
let response = self.get_all_vouchers().await?;
|
||||
|
||||
// Find the most recent rolling voucher
|
||||
let rolling = response
|
||||
.data
|
||||
.iter()
|
||||
.filter(|voucher| voucher.name.starts_with(ROLLING_VOUCHER_NAME_PREFIX))
|
||||
.max_by_key(|voucher| {
|
||||
DateTime::parse_from_str(&voucher.created_at, DATE_TIME_FORMAT)
|
||||
.unwrap_or_else(|_| DateTime::UNIX_EPOCH.fixed_offset())
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Ok(rolling)
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher(&self) -> Result<Voucher, StatusCode> {
|
||||
let response = self.get_all_vouchers().await?;
|
||||
|
||||
if response.data.is_empty() {
|
||||
@@ -222,7 +254,8 @@ impl<'a> UnifiAPI<'a> {
|
||||
DateTime::parse_from_str(&voucher.created_at, DATE_TIME_FORMAT)
|
||||
.unwrap_or_else(|_| DateTime::UNIX_EPOCH.fixed_offset())
|
||||
})
|
||||
.cloned();
|
||||
.cloned()
|
||||
.expect("At least one voucher should exist");
|
||||
|
||||
Ok(newest)
|
||||
}
|
||||
@@ -249,6 +282,52 @@ impl<'a> UnifiAPI<'a> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn check_rolling_voucher_ip(&self, ip: &str) -> Result<bool, StatusCode> {
|
||||
let response = self.get_all_vouchers().await?;
|
||||
|
||||
// Find a rolling voucher that contains the given IP address
|
||||
let rolling = response
|
||||
.data
|
||||
.iter()
|
||||
.find(|voucher| {
|
||||
!voucher.expired
|
||||
&& voucher.name.starts_with(ROLLING_VOUCHER_NAME_PREFIX)
|
||||
&& voucher.name.ends_with(ip)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Ok(rolling.is_some())
|
||||
}
|
||||
|
||||
pub async fn create_rolling_voucher(&self, ip: &str) -> Result<Voucher, StatusCode> {
|
||||
let request = CreateVoucherRequest {
|
||||
count: 1,
|
||||
name: format!(
|
||||
"{} {}-{}",
|
||||
ROLLING_VOUCHER_NAME_PREFIX,
|
||||
chrono::Local::now().format("%Y%m%d%H%M%S"),
|
||||
ip
|
||||
),
|
||||
time_limit_minutes: self.environment.rolling_voucher_duration_minutes,
|
||||
authorized_guest_limit: None,
|
||||
data_usage_limit_mbytes: None,
|
||||
tx_rate_limit_kbps: None,
|
||||
rx_rate_limit_kbps: None,
|
||||
};
|
||||
|
||||
let rolling = self
|
||||
.create_voucher(request)
|
||||
.await?
|
||||
.vouchers
|
||||
.first()
|
||||
.cloned();
|
||||
|
||||
match rolling {
|
||||
Some(v) => Ok(v),
|
||||
None => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_vouchers_by_ids(
|
||||
&self,
|
||||
ids: Vec<String>,
|
||||
@@ -280,17 +359,14 @@ impl<'a> UnifiAPI<'a> {
|
||||
self.make_request(RequestType::Delete, &url, None::<&()>)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unifi_date(rfc3339_string: &str, target_timezone: &Tz) -> String {
|
||||
match DateTime::parse_from_rfc3339(rfc3339_string) {
|
||||
Ok(dt) => {
|
||||
let local_time = dt.with_timezone(target_timezone);
|
||||
local_time.format(DATE_TIME_FORMAT).to_string()
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Failed to parse RFC3339 date: {}", rfc3339_string);
|
||||
rfc3339_string.to_string()
|
||||
}
|
||||
pub async fn delete_expired_rolling_vouchers(&self) -> Result<DeleteResponse, StatusCode> {
|
||||
let url = format!(
|
||||
"{}?filter=and(expired.eq(true),name.like('{}*'))",
|
||||
self.voucher_api_url,
|
||||
utf8_percent_encode(ROLLING_VOUCHER_NAME_PREFIX, FRAGMENT)
|
||||
);
|
||||
self.make_request(RequestType::Delete, &url, None::<&()>)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@@ -21,5 +21,7 @@ services:
|
||||
UNIFI_CONTROLLER_URL: "URL to your UniFi controller with protocol (`http://` or `https://`)."
|
||||
UNIFI_API_KEY: "API Key for your UniFi controller."
|
||||
UNIFI_HAS_VALID_CERT: "true" # Set to false only if your UniFi controller does not use a valid SSL certificate. Typically, this should remain true when accessing the controller (UNIFI_CONTROLLER_URL) through a reverse proxy or any setup providing trusted SSL certificates (e.g., Let's Encrypt).
|
||||
WIFI_SSID: "Your guest WiFi SSID" # Optional
|
||||
WIFI_PASSWORD: "Youw guest WiFi password" # Optional
|
||||
WIFI_SSID: "Your guest WiFi SSID" # Optional, but recommended
|
||||
WIFI_PASSWORD: "Youw guest WiFi password" # Optional, but recommended
|
||||
GUEST_SUBNETWORK: "Your guest subnetwork in IPv4 CIDR notation (X.X.X.X/X)" # Optional, but recommended
|
||||
TIMEZONE: "Your timezone (America/Toronto)" # Optional, but recommended
|
||||
|
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
public/runtime-config.js
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
37
frontend/src/app/api/events/route.ts
Normal file
37
frontend/src/app/api/events/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { sseManager } from "@/utils/sseManager";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const clientId = randomUUID();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
sseManager.addClient(clientId, controller);
|
||||
|
||||
// Send initial connection message
|
||||
const welcomeMessage = `data: ${JSON.stringify({
|
||||
type: "connected",
|
||||
clientId: clientId,
|
||||
})}\n\n`;
|
||||
|
||||
controller.enqueue(welcomeMessage);
|
||||
|
||||
// Clean up when connection closes
|
||||
request.signal.addEventListener("abort", () => {
|
||||
sseManager.removeClient(clientId);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
sseManager.removeClient(clientId);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
@@ -215,11 +215,8 @@
|
||||
@utility hover-lift {
|
||||
@apply hover:-translate-y-1 hover:shadow-elevation hover:dark:shadow-elevation-dark;
|
||||
}
|
||||
@utility hover-subtle {
|
||||
@apply bg-interactive-hover;
|
||||
}
|
||||
@utility hover-scale {
|
||||
@apply hover:scale-105;
|
||||
@apply hover:scale-105 disabled:hover:scale-100;
|
||||
}
|
||||
|
||||
/* Slide Animations */
|
||||
|
77
frontend/src/app/kiosk/page.tsx
Normal file
77
frontend/src/app/kiosk/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
import WifiQr from "@/components/utils/WifiQr";
|
||||
import { TriState } from "@/types/state";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import { api } from "@/utils/api";
|
||||
import { formatCode } from "@/utils/format";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
|
||||
export default function KioskPage() {
|
||||
const [voucher, setVoucher] = useState<Voucher | null>(null);
|
||||
const [state, setState] = useState<TriState | null>(null);
|
||||
const { wifiConfig, wifiString } = useGlobal();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (state === "loading") return;
|
||||
try {
|
||||
setState("loading");
|
||||
await api.getRollingVoucher().then(setVoucher);
|
||||
setState("ok");
|
||||
} catch (error: any) {
|
||||
if (error?.status !== 404) {
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.createRollingVoucher().then(setVoucher);
|
||||
setState("ok");
|
||||
} catch {
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
window.addEventListener("vouchersUpdated", load);
|
||||
return () => window.removeEventListener("vouchersUpdated", load);
|
||||
}, [load]);
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
switch (state) {
|
||||
case null:
|
||||
case "loading":
|
||||
return <Spinner />;
|
||||
case "error":
|
||||
return (
|
||||
<div className="text-center text-5xl sm:text-6xl md:text-7xl text-status-danger">
|
||||
Could not load rolling voucher
|
||||
</div>
|
||||
);
|
||||
case "ok":
|
||||
const qrAvailable = wifiConfig && wifiString;
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-1 ${qrAvailable && "md:grid-cols-2 "} gap-8 items-center`}
|
||||
>
|
||||
{qrAvailable && <WifiQr className="w-full sm:h-80 md:h-96 " />}
|
||||
<div className={`text-center ${qrAvailable && "md:text-left"}`}>
|
||||
<h2 className="font-medium mb-4 text-3xl sm:text-4xl md:text-5xl">
|
||||
Voucher Code
|
||||
</h2>
|
||||
<div className="voucher-code tracking-widest text-5xl sm:text-6xl md:text-7xl">
|
||||
{voucher ? formatCode(voucher.code) : "No voucher available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [voucher, state, wifiConfig, wifiString]);
|
||||
|
||||
return (
|
||||
<main className="flex-center h-screen w-full px-4">{renderContent()}</main>
|
||||
);
|
||||
}
|
@@ -13,12 +13,39 @@ import {
|
||||
} from "@/utils/format";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
import { formatCode } from "@/utils/format";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
|
||||
export type PrintMode = "list" | "grid";
|
||||
|
||||
function VoucherBlock({ voucher }: { voucher: Voucher }) {
|
||||
// This component represents a single voucher card to be printed
|
||||
function VoucherPrintCard({ voucher }: { voucher: Voucher }) {
|
||||
const { wifiConfig, wifiString } = useGlobal();
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: "Duration",
|
||||
value: formatDuration(voucher.timeLimitMinutes),
|
||||
},
|
||||
{
|
||||
label: "Max Guests",
|
||||
value: formatMaxGuests(voucher.authorizedGuestLimit),
|
||||
},
|
||||
{
|
||||
label: "Data Limit",
|
||||
value: voucher.dataUsageLimitMBytes
|
||||
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
|
||||
: "Unlimited",
|
||||
},
|
||||
{
|
||||
label: "Down Speed",
|
||||
value: formatSpeed(voucher.rxRateLimitKbps),
|
||||
},
|
||||
{
|
||||
label: "Up Speed",
|
||||
value: formatSpeed(voucher.txRateLimitKbps),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="print-voucher">
|
||||
<div className="print-header">
|
||||
@@ -27,38 +54,12 @@ function VoucherBlock({ voucher }: { voucher: Voucher }) {
|
||||
|
||||
<div className="print-voucher-code">{formatCode(voucher.code)}</div>
|
||||
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">Duration:</span>
|
||||
<span className="print-value">
|
||||
{formatDuration(voucher.timeLimitMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">Max Guests:</span>
|
||||
<span className="print-value">
|
||||
{formatMaxGuests(voucher.authorizedGuestLimit)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">Data Limit:</span>
|
||||
<span className="print-value">
|
||||
{voucher.dataUsageLimitMBytes
|
||||
? formatBytes(voucher.dataUsageLimitMBytes * 1024 * 1024)
|
||||
: "Unlimited"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">Down Speed:</span>
|
||||
<span className="print-value">
|
||||
{formatSpeed(voucher.rxRateLimitKbps)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">Up Speed:</span>
|
||||
<span className="print-value">
|
||||
{formatSpeed(voucher.txRateLimitKbps)}
|
||||
</span>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<div className="print-info-row">
|
||||
<span className="print-label">{field.label}:</span>
|
||||
<span className="print-value">{field.value}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{wifiConfig && (
|
||||
<div className="print-qr-section">
|
||||
@@ -102,6 +103,7 @@ function VoucherBlock({ voucher }: { voucher: Voucher }) {
|
||||
);
|
||||
}
|
||||
|
||||
// This component handles displaying and printing the vouchers based on URL params
|
||||
function Vouchers() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -144,12 +146,13 @@ function Vouchers() {
|
||||
) : (
|
||||
<div className={mode === "grid" ? "print-grid" : "print-list"}>
|
||||
{vouchers.map((v) => (
|
||||
<VoucherBlock key={v.id} voucher={v} />
|
||||
<VoucherPrintCard key={v.id} voucher={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// This sets up the print page itself
|
||||
export default function PrintPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -165,12 +168,10 @@ export default function PrintPage() {
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="print-wrapper">
|
||||
<Suspense
|
||||
fallback={<div style={{ textAlign: "center" }}>Loading...</div>}
|
||||
>
|
||||
<main className="print-wrapper">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Vouchers />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
49
frontend/src/app/welcome/page.tsx
Normal file
49
frontend/src/app/welcome/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "@/utils/api";
|
||||
import { getRuntimeConfig } from "@/utils/runtimeConfig";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export default function WelcomePage() {
|
||||
const [visited, setVisited] = useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
const ssid = useMemo(() => {
|
||||
if (!hasMounted) return null;
|
||||
const { WIFI_SSID: ssid } = getRuntimeConfig();
|
||||
return ssid;
|
||||
}, [hasMounted, getRuntimeConfig]);
|
||||
|
||||
const rotateVoucher = useCallback(async () => {
|
||||
try {
|
||||
await api.createRollingVoucher();
|
||||
} catch (error: any) {
|
||||
// Error 403 is expected if the user already created a rolling voucher
|
||||
if (error?.status !== 403) {
|
||||
console.error("Failed to create rolling voucher", error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
|
||||
if (visited) return;
|
||||
rotateVoucher();
|
||||
setVisited(true);
|
||||
}, [rotateVoucher]);
|
||||
|
||||
return (
|
||||
<main className="flex-center h-screen w-full px-4">
|
||||
<div className="w-full text-center font-bold text-4xl sm:text-5xl md:text-7xl lg:text-9xl leading-snug">
|
||||
{ssid ? (
|
||||
<>
|
||||
Welcome to <span className="text-brand font-mono">{ssid}</span>!
|
||||
</>
|
||||
) : (
|
||||
"Welcome!"
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
@@ -1,14 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
|
||||
import WifiQrModal from "@/components/modals/WifiQrModal";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Header() {
|
||||
const [showWifi, setShowWifi] = useState(false);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const { wifiConfig } = useGlobal();
|
||||
const router = useRouter();
|
||||
const { wifiConfig, wifiString } = useGlobal();
|
||||
const qrAvailable: boolean = useMemo(
|
||||
() => !!(wifiConfig && wifiString),
|
||||
[wifiConfig, wifiString],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial height and update on resize
|
||||
@@ -31,15 +37,24 @@ export default function Header() {
|
||||
ref={headerRef}
|
||||
className="bg-surface border-b border-default sticky top-0 z-7000"
|
||||
>
|
||||
<div className="max-w-95/100 mx-auto flex-center-between px-4 py-4">
|
||||
<div className="max-w-95/100 mx-auto flex-center-between px-4 py-4 gap-4">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-brand">
|
||||
UniFi Voucher Manager
|
||||
<span className="block sm:hidden">UVM</span>
|
||||
<span className="hidden sm:block">UniFi Voucher Manager</span>
|
||||
</h1>
|
||||
<div className="flex-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/kiosk")}
|
||||
className="btn text-sm p-1 px-2"
|
||||
aria-label="Open Kiosk"
|
||||
title="Open Kiosk"
|
||||
>
|
||||
📺
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowWifi(true)}
|
||||
className="btn p-1"
|
||||
disabled={!wifiConfig}
|
||||
disabled={!qrAvailable}
|
||||
aria-label="Open Wi‑Fi QR code"
|
||||
title="Open Wi‑Fi QR code"
|
||||
>
|
||||
@@ -54,7 +69,7 @@ export default function Header() {
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{showWifi && wifiConfig && (
|
||||
{qrAvailable && showWifi && (
|
||||
<WifiQrModal onClose={() => setShowWifi(false)} />
|
||||
)}
|
||||
</header>
|
||||
|
@@ -1,5 +1,10 @@
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import { formatCode, formatDuration, formatGuestUsage } from "@/utils/format";
|
||||
import {
|
||||
formatCode,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
formatStatus,
|
||||
} from "@/utils/format";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
@@ -12,7 +17,9 @@ type Props = {
|
||||
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
const statusClass = voucher.expired
|
||||
? "bg-status-danger text-status-danger"
|
||||
: "bg-status-success text-status-success";
|
||||
: voucher.activatedAt
|
||||
? "bg-status-warning text-status-warning"
|
||||
: "bg-status-success text-status-success";
|
||||
const onClickHandler = useCallback(
|
||||
() => onClick?.(voucher),
|
||||
[voucher, onClick],
|
||||
@@ -69,7 +76,7 @@ const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
<span
|
||||
className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
|
||||
>
|
||||
{voucher.expired ? "Expired" : "Active"}
|
||||
{formatStatus(voucher.expired, voucher.activatedAt)}
|
||||
</span>
|
||||
{voucher.expiresAt && (
|
||||
<span className="text-xs">Expires: {voucher.expiresAt}</span>
|
||||
|
@@ -46,10 +46,22 @@ export default function Modal({
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-0 right-2 text-secondary text-2xl hover:text-primary"
|
||||
className="absolute top-4 right-4 p-1 flex-center rounded-full text-secondary hover:text-primary hover-scale btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="overflow-y-auto mr-3 mt-8 mb-2 p-6">{children}</div>
|
||||
</div>
|
||||
|
@@ -3,15 +3,17 @@
|
||||
import Modal from "@/components/modals/Modal";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
formatSpeed,
|
||||
formatStatus,
|
||||
} from "@/utils/format";
|
||||
import VoucherCode from "@/components/utils/VoucherCode";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import { TriState } from "@/types/state";
|
||||
|
||||
type Props = {
|
||||
voucher: Voucher;
|
||||
@@ -20,8 +22,7 @@ type Props = {
|
||||
|
||||
export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
const [details, setDetails] = useState<Voucher | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [state, setState] = useState<TriState | null>(null);
|
||||
const lastFetchedId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,70 +32,81 @@ export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setState("loading");
|
||||
lastFetchedId.current = voucher.id;
|
||||
|
||||
try {
|
||||
const res = await api.getVoucherDetails(voucher.id);
|
||||
setDetails(res);
|
||||
setState("ok");
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setState("error");
|
||||
}
|
||||
})();
|
||||
}, [voucher.id]);
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
switch (state) {
|
||||
case null:
|
||||
case "loading":
|
||||
return <Spinner />;
|
||||
case "error":
|
||||
return (
|
||||
<div className="card text-status-danger text-center">
|
||||
Failed to load detailed information
|
||||
</div>
|
||||
);
|
||||
case "ok":
|
||||
if (details == null) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(
|
||||
[
|
||||
["Status", formatStatus(details.expired, details.activatedAt)],
|
||||
["Name", details.name || "No note"],
|
||||
["Created", details.createdAt],
|
||||
...(details.activatedAt
|
||||
? [["Activated", details.activatedAt]]
|
||||
: []),
|
||||
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
|
||||
["Duration", formatDuration(details.timeLimitMinutes)],
|
||||
[
|
||||
"Guest Usage",
|
||||
formatGuestUsage(
|
||||
details.authorizedGuestCount,
|
||||
details.authorizedGuestLimit,
|
||||
),
|
||||
],
|
||||
[
|
||||
"Data Limit",
|
||||
details.dataUsageLimitMBytes
|
||||
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
|
||||
: "Unlimited",
|
||||
],
|
||||
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
|
||||
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
|
||||
["ID", details.id],
|
||||
] as [string, any][]
|
||||
).map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
|
||||
>
|
||||
<span className="font-semibold text-primary">{label}:</span>
|
||||
<span className="text-secondary">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [state, details]);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<VoucherCode voucher={voucher} contentClassName="mb-8" />
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error || details == null ? (
|
||||
<div className="card text-status-danger text-center">
|
||||
Failed to load detailed information
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(
|
||||
[
|
||||
["Status", details.expired ? "Expired" : "Active"],
|
||||
["Name", details.name || "No note"],
|
||||
["Created", details.createdAt],
|
||||
...(details.activatedAt
|
||||
? [["Activated", details.activatedAt]]
|
||||
: []),
|
||||
...(details.expiresAt ? [["Expires", details.expiresAt]] : []),
|
||||
["Duration", formatDuration(details.timeLimitMinutes)],
|
||||
[
|
||||
"Guest Usage",
|
||||
formatGuestUsage(
|
||||
details.authorizedGuestCount,
|
||||
details.authorizedGuestLimit,
|
||||
),
|
||||
],
|
||||
[
|
||||
"Data Limit",
|
||||
details.dataUsageLimitMBytes
|
||||
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
|
||||
: "Unlimited",
|
||||
],
|
||||
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
|
||||
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
|
||||
["ID", details.id],
|
||||
] as [string, any][]
|
||||
).map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-center-between p-4 bg-interactive border border-subtle rounded-xl space-x-4"
|
||||
>
|
||||
<span className="font-semibold text-primary">{label}:</span>
|
||||
<span className="text-secondary">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
@@ -1,69 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import Modal from "@/components/modals/Modal";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
import WifiQr from "@/components/utils/WifiQr";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function WifiQrModal({ onClose }: Props) {
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [qrSize, setQrSize] = useState(220);
|
||||
const { wifiConfig, wifiString } = useGlobal();
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
if (modalRef.current) {
|
||||
// Make sure the QR code scales with the size of the modal
|
||||
const modalWidth = modalRef.current.offsetWidth;
|
||||
const modalHeight = modalRef.current.offsetHeight;
|
||||
const sizeFromWidth = modalWidth * 0.8;
|
||||
const sizeFromHeight = modalHeight * 0.8;
|
||||
setQrSize(Math.floor(Math.min(sizeFromWidth, sizeFromHeight)));
|
||||
}
|
||||
}
|
||||
updateSize();
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal ref={modalRef} onClose={onClose} contentClassName="max-w-md">
|
||||
<Modal onClose={onClose} contentClassName="max-w-md">
|
||||
<div className="flex-center flex-col gap-4">
|
||||
<h2 className="text-2xl font-bold text-primary text-center">
|
||||
Wi‑Fi QR Code
|
||||
Wi-Fi QR Code
|
||||
</h2>
|
||||
{wifiConfig && wifiString ? (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
value={wifiString}
|
||||
size={qrSize}
|
||||
level="H"
|
||||
bgColor="transparent"
|
||||
fgColor="currentColor"
|
||||
marginSize={4}
|
||||
title="Wi-Fi Access QR Code"
|
||||
imageSettings={{
|
||||
src: "/unifi.svg",
|
||||
height: qrSize / 4,
|
||||
width: qrSize / 4,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted">
|
||||
Scan this QR code to join the network:{" "}
|
||||
<strong>{wifiConfig.ssid}</strong>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted text-center">
|
||||
No Wi‑Fi credentials configured.
|
||||
</p>
|
||||
)}
|
||||
<WifiQr className="w-full h-72" sizeRatio={0.88} />
|
||||
|
||||
<p className="text-sm text-muted text-center">
|
||||
Scan this QR code to join the network
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
@@ -56,7 +56,7 @@ export default function Tabs() {
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 overflow-y-auto">
|
||||
<main className="p-4 overflow-y-auto">
|
||||
{enabledTabs.map((tabConfig) => {
|
||||
const Component = tabConfig.component;
|
||||
return (
|
||||
@@ -68,7 +68,7 @@ export default function Tabs() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -93,8 +93,8 @@ export default function VouchersTab() {
|
||||
try {
|
||||
const res =
|
||||
kind === "selected"
|
||||
? await api.deleteSelected([...selectedVouchers.map((v) => v.id)])
|
||||
: await api.deleteSelected([...expiredIds]);
|
||||
? await api.deleteSelectedVouchers([...selectedVouchers.map((v) => v.id)])
|
||||
: await api.deleteSelectedVouchers([...expiredIds]);
|
||||
|
||||
const count = res.vouchersDeleted || 0;
|
||||
if (count > 0) {
|
||||
|
@@ -42,7 +42,7 @@ export default function VoucherCode({ voucher, contentClassName = "" }: Props) {
|
||||
</div>
|
||||
<div className="flex-center gap-3">
|
||||
<button onClick={handleCopy} className="btn-success">
|
||||
{copied ? "Copied" : "Copy Code"}
|
||||
Copy Code
|
||||
</button>
|
||||
<button onClick={handlePrint} className="btn-primary">
|
||||
Print Voucher
|
||||
|
88
frontend/src/components/utils/WifiQr.tsx
Normal file
88
frontend/src/components/utils/WifiQr.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useGlobal } from "@/contexts/GlobalContext";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
/** Fraction of the smaller parent dimension to use for the QR (0 < n <= 1). Default 0.8 */
|
||||
sizeRatio?: number;
|
||||
/** Fixed size override (in px). If provided, this takes precedence over automatic sizing. */
|
||||
overrideSize?: number;
|
||||
/** URL for the logo inside the QR. Default uses /unifi.svg like the original. */
|
||||
imageSrc?: string;
|
||||
};
|
||||
|
||||
export default function WifiQr({
|
||||
className,
|
||||
sizeRatio = 0.8,
|
||||
overrideSize,
|
||||
imageSrc = "/unifi.svg",
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [qrSize, setQrSize] = useState<number>(220);
|
||||
const { wifiConfig, wifiString } = useGlobal();
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideSize && overrideSize > 0) {
|
||||
setQrSize(Math.floor(overrideSize));
|
||||
return;
|
||||
}
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
function updateFromRect(width: number, height: number) {
|
||||
const fromWidth = width * sizeRatio;
|
||||
const fromHeight = height * sizeRatio;
|
||||
const newSize = Math.max(32, Math.floor(Math.min(fromWidth, fromHeight)));
|
||||
setQrSize(newSize);
|
||||
}
|
||||
|
||||
// Initial measurement
|
||||
const rect = element.getBoundingClientRect();
|
||||
updateFromRect(rect.width, rect.height);
|
||||
|
||||
// Observe size changes of the parent container
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const contentRect = entry.contentRect;
|
||||
updateFromRect(contentRect.width, contentRect.height);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [sizeRatio, overrideSize]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`flex-center ${className}`}>
|
||||
<div className="flex-center flex-col gap-4 text-center">
|
||||
{wifiConfig && wifiString ? (
|
||||
<>
|
||||
<QRCodeSVG
|
||||
value={wifiString}
|
||||
size={qrSize}
|
||||
level="H"
|
||||
bgColor="transparent"
|
||||
fgColor="currentColor"
|
||||
title={`Wi-Fi access: ${wifiConfig.ssid}`}
|
||||
imageSettings={{
|
||||
src: imageSrc,
|
||||
height: Math.floor(qrSize / 4),
|
||||
width: Math.floor(qrSize / 4),
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted">
|
||||
Scan to join <strong>{wifiConfig.ssid}</strong>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No Wi‑Fi credentials configured.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Theme } from "@/components/utils/ThemeSwitcher";
|
||||
import { useServerEvents } from "@/hooks/useServerEvents";
|
||||
import {
|
||||
generateWifiConfig,
|
||||
generateWiFiQRString,
|
||||
@@ -22,8 +23,8 @@ export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}) => {
|
||||
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
|
||||
const [wifiString, setWifiString] = useState<string | null>(null);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
useServerEvents();
|
||||
|
||||
// WiFi setup
|
||||
useEffect(() => {
|
||||
|
89
frontend/src/hooks/useServerEvents.ts
Normal file
89
frontend/src/hooks/useServerEvents.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
function getBackoffDelay(attempt: number, base = 1000, max = 30000) {
|
||||
const jitter = Math.random() * 0.3 + 0.85; // 85–115% random factor
|
||||
return Math.min(base * Math.pow(2, attempt), max) * jitter;
|
||||
}
|
||||
|
||||
export function useServerEvents() {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Avoid reconnecting if already connecting/open
|
||||
if (
|
||||
eventSourceRef.current &&
|
||||
(eventSourceRef.current.readyState === EventSource.OPEN ||
|
||||
eventSourceRef.current.readyState === EventSource.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close existing connection if any
|
||||
eventSourceRef.current?.close();
|
||||
|
||||
console.log("Setting up SSE connection...");
|
||||
eventSourceRef.current = new EventSource("/api/events");
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
console.log("SSE connection opened");
|
||||
reconnectAttempts.current = 0;
|
||||
};
|
||||
|
||||
eventSourceRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "connected":
|
||||
console.log(`SSE connected with clientId: ${data.clientId}`);
|
||||
break;
|
||||
case "vouchersUpdated":
|
||||
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown SSE event type:", data.type);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing SSE data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSourceRef.current.onerror = (_error) => {
|
||||
console.log("SSE connection error, attempting to reconnect...");
|
||||
|
||||
// Close the current connection
|
||||
eventSourceRef.current?.close();
|
||||
|
||||
// Only attempt to reconnect if we haven't exceeded max attempts
|
||||
if (reconnectAttempts.current < maxReconnectAttempts) {
|
||||
reconnectAttempts.current++;
|
||||
const delay = getBackoffDelay(reconnectAttempts.current);
|
||||
|
||||
console.log(
|
||||
`Reconnecting in ${Math.round(delay)}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`,
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||
} else {
|
||||
console.error("Max reconnection attempts reached, giving up");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear any pending reconnection attempts
|
||||
reconnectTimeoutRef.current && clearTimeout(reconnectTimeoutRef.current);
|
||||
|
||||
// Close the connection
|
||||
eventSourceRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return eventSourceRef.current;
|
||||
}
|
@@ -1,17 +1,63 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { isInBlockedSubnet } from "@/utils/ipv4";
|
||||
|
||||
export const config = {
|
||||
matcher: "/api/:path*",
|
||||
matcher: ["/", "/rust-api/:path*"],
|
||||
};
|
||||
|
||||
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
||||
const DEFAULT_BACKEND_BIND_PORT = "8080";
|
||||
|
||||
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
|
||||
|
||||
const guestAllowedPaths = [
|
||||
"/welcome",
|
||||
"/rust-api/vouchers/rolling",
|
||||
"favicon.ico",
|
||||
"favicon.svg",
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(
|
||||
`${process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL}:${process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT}${request.nextUrl.pathname}${request.nextUrl.search}`,
|
||||
),
|
||||
{ request },
|
||||
);
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Extract client IP
|
||||
let clientIp = request.headers.get("x-forwarded-for") || "";
|
||||
|
||||
// Strip IPv6 prefix if it's a mapped IPv4
|
||||
if (clientIp.startsWith(IPV6_IPV4_MAPPED_PREFIX)) {
|
||||
clientIp = clientIp.replace(IPV6_IPV4_MAPPED_PREFIX, "");
|
||||
}
|
||||
|
||||
// Restrict access based on GUEST_SUBNET env variable
|
||||
const guestSubnet = process.env.GUEST_SUBNET;
|
||||
if (guestSubnet) {
|
||||
if (
|
||||
!guestAllowedPaths.includes(pathname) &&
|
||||
isInBlockedSubnet(clientIp, guestSubnet)
|
||||
) {
|
||||
return new NextResponse("Access denied", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/rust-api")) {
|
||||
// Remove the /rust-api prefix and reconstruct the path for the backend
|
||||
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
|
||||
|
||||
const backendUrl =
|
||||
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
|
||||
const backendPort =
|
||||
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
|
||||
|
||||
const backendFullUrl = new URL(
|
||||
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
|
||||
);
|
||||
|
||||
const response = NextResponse.rewrite(backendFullUrl, { request });
|
||||
|
||||
// Forward the real client IP
|
||||
response.headers.set("x-forwarded-for", clientIp);
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
1
frontend/src/types/state.ts
Normal file
1
frontend/src/types/state.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type TriState = "loading" | "error" | "ok";
|
10
frontend/src/utils/actions.ts
Normal file
10
frontend/src/utils/actions.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { sseManager } from "./sseManager";
|
||||
|
||||
export async function notifyVouchersUpdated() {
|
||||
sseManager.broadcastToClients({
|
||||
type: "vouchersUpdated",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
@@ -4,6 +4,7 @@ import {
|
||||
VoucherCreatedResponse,
|
||||
VoucherDeletedResponse,
|
||||
} from "@/types/voucher";
|
||||
import { notifyVouchersUpdated } from "./actions";
|
||||
|
||||
function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
@@ -14,21 +15,25 @@ function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
}
|
||||
|
||||
async function call<T>(endpoint: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/${endpoint}`, {
|
||||
const res = await fetch(`/rust-api/${endpoint}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
if (!res.ok) {
|
||||
const error = new Error(res.statusText);
|
||||
(error as any).status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function notifyVouchersUpdated() {
|
||||
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getAllVouchers: () => call<{ data: Voucher[] }>("/vouchers"),
|
||||
|
||||
getRollingVoucher: () => call<Voucher>("/vouchers/rolling"),
|
||||
|
||||
getNewestVoucher: () => call<Voucher>("/vouchers/newest"),
|
||||
|
||||
getVoucherDetails: (id: string) =>
|
||||
call<Voucher>(`/vouchers/details?id=${encodeURIComponent(id)}`),
|
||||
|
||||
@@ -38,19 +43,38 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(filteredData),
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteExpired: async () => {
|
||||
createRollingVoucher: async () => {
|
||||
const result = await call<Voucher>("/vouchers/rolling", {
|
||||
method: "POST",
|
||||
});
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteExpiredVouchers: async () => {
|
||||
const result = await call<VoucherDeletedResponse>("/vouchers/expired", {
|
||||
method: "DELETE",
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteSelected: async (ids: string[]) => {
|
||||
deleteExpiredRollingVouchers: async () => {
|
||||
const result = await call<VoucherDeletedResponse>(
|
||||
"/vouchers/expired/rolling",
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteSelectedVouchers: async (ids: string[]) => {
|
||||
const qs = ids.map(encodeURIComponent).join(",");
|
||||
const result = await call<VoucherDeletedResponse>(
|
||||
`/vouchers/selected?ids=${qs}`,
|
||||
@@ -58,7 +82,7 @@ export const api = {
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
@@ -6,6 +6,15 @@ export function formatMaxGuests(maxGuests: number | null | undefined) {
|
||||
return !maxGuests ? "Unlimited" : Math.max(maxGuests, 0);
|
||||
}
|
||||
|
||||
export function formatStatus(
|
||||
expired: boolean,
|
||||
activatedAt: string | null | undefined,
|
||||
) {
|
||||
if (expired) return "Expired";
|
||||
if (activatedAt) return "Active";
|
||||
return "Available";
|
||||
}
|
||||
|
||||
export function formatDuration(m: number | null | undefined) {
|
||||
if (!m) return "Unlimited";
|
||||
const days = Math.floor(m / 1440),
|
||||
|
145
frontend/src/utils/ipv4.ts
Normal file
145
frontend/src/utils/ipv4.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Validate subnet format and values
|
||||
export function isValidSubnet(subnet: string): boolean {
|
||||
try {
|
||||
// Handle single IP addresses (treat as /32)
|
||||
if (!subnet.includes("/")) {
|
||||
return isValidIPAddress(subnet);
|
||||
}
|
||||
|
||||
// Parse the subnet (e.g., "10.0.5.0/24")
|
||||
const [subnetIp, prefixLength] = subnet.split("/");
|
||||
|
||||
if (!subnetIp || !prefixLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate prefix length
|
||||
const prefix = parseInt(prefixLength, 10);
|
||||
if (isNaN(prefix) || prefix < 0 || prefix > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate IP address format
|
||||
if (!isValidIPAddress(subnetIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the IP is a valid network address
|
||||
const ipToInt = (ipAddr: string): number => {
|
||||
const parts = ipAddr.split(".");
|
||||
return (
|
||||
parts.reduce((acc, part) => {
|
||||
const num = parseInt(part, 10);
|
||||
return (acc << 8) + num;
|
||||
}, 0) >>> 0
|
||||
);
|
||||
};
|
||||
|
||||
const subnetIpInt = ipToInt(subnetIp);
|
||||
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||
const networkAddress = (subnetIpInt & mask) >>> 0;
|
||||
|
||||
// Check if the provided IP is actually the network address
|
||||
// Comment out these lines if you want to allow any IP in subnet notation
|
||||
if (subnetIpInt !== networkAddress) {
|
||||
console.warn(
|
||||
`IP ${subnetIp} is not a network address for /${prefix}. Expected: ${intToIp(networkAddress)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility export function to validate IP address format
|
||||
// Taken from https://stackoverflow.com/a/36760050
|
||||
export function isValidIPAddress(ip: string): boolean {
|
||||
const ipRegex = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||
return ipRegex.test(ip);
|
||||
}
|
||||
|
||||
// Helper export function to convert integer back to IP string
|
||||
export function intToIp(int: number): string {
|
||||
return [
|
||||
(int >>> 24) & 255,
|
||||
(int >>> 16) & 255,
|
||||
(int >>> 8) & 255,
|
||||
int & 255,
|
||||
].join(".");
|
||||
}
|
||||
|
||||
// Robust subnet check with proper CIDR notation support
|
||||
export function isInBlockedSubnet(ip: string, subnet: string): boolean {
|
||||
if (!ip || !subnet) return false;
|
||||
|
||||
// Validate inputs first
|
||||
if (!isValidIPAddress(ip)) {
|
||||
console.warn(`Invalid IP address format: ${ip}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidSubnet(subnet)) {
|
||||
console.warn(`Invalid subnet format: ${subnet}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize subnet (add /32 for single IPs)
|
||||
const normalizedSubnet = subnet.includes("/") ? subnet : `${subnet}/32`;
|
||||
const [subnetIp, prefixLength] = normalizedSubnet.split("/");
|
||||
const prefix = parseInt(prefixLength, 10);
|
||||
|
||||
// Convert IP addresses to 32-bit integers
|
||||
const ipToInt = (ipAddr: string): number => {
|
||||
const parts = ipAddr.split(".");
|
||||
return (
|
||||
parts.reduce((acc, part) => {
|
||||
const num = parseInt(part, 10);
|
||||
return (acc << 8) + num;
|
||||
}, 0) >>> 0
|
||||
);
|
||||
};
|
||||
|
||||
const targetIpInt = ipToInt(ip);
|
||||
const subnetIpInt = ipToInt(subnetIp);
|
||||
|
||||
// Create subnet mask
|
||||
const mask = (0xffffffff << (32 - prefix)) >>> 0;
|
||||
|
||||
// Check if the IP is in the subnet
|
||||
return (targetIpInt & mask) === (subnetIpInt & mask);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error checking subnet: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced version with support for multiple subnets
|
||||
export function isInAnyBlockedSubnet(ip: string, subnets: string[]): boolean {
|
||||
if (subnets.length === 0) return false;
|
||||
|
||||
// Validate IP once
|
||||
if (!isValidIPAddress(ip)) {
|
||||
console.warn(`Invalid IP address format: ${ip}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter valid subnets and check
|
||||
const validSubnets = subnets.filter((subnet) => {
|
||||
const isValid = isValidSubnet(subnet);
|
||||
if (!isValid) {
|
||||
console.warn(`Skipping invalid subnet: ${subnet}`);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
return validSubnets.some((subnet) => isInBlockedSubnet(ip, subnet));
|
||||
}
|
@@ -8,25 +8,39 @@ export interface NotificationPayload {
|
||||
|
||||
/** Generate a RFC-4122 v4 UUID */
|
||||
function generateUUID(): string {
|
||||
// use crypto.randomUUID() when available
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
// @ts-ignore
|
||||
if (crypto && crypto.randomUUID) {
|
||||
// Use crypto.randomUUID() when available
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
} else if (crypto && crypto.getRandomValues) {
|
||||
// Fallback to crypto.getRandomValues
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
|
||||
// fallback to crypto.getRandomValues
|
||||
let d = new Date().getTime();
|
||||
let d2 = (performance && performance.now && performance.now() * 1000) || 0;
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r =
|
||||
Math.random() * 16 +
|
||||
// use high-res entropy if available
|
||||
(crypto && crypto.getRandomValues
|
||||
? crypto.getRandomValues(new Uint8Array(1))[0]
|
||||
: 0);
|
||||
const v = c === "x" ? r % 16 | 0 : (r % 16 & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
// Per RFC 4122, set bits for version and `clock_seq_hi_and_reserved`
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
|
||||
|
||||
const toHex = (b: number) => b.toString(16).padStart(2, "0");
|
||||
|
||||
return [
|
||||
toHex(bytes[0]) + toHex(bytes[1]) + toHex(bytes[2]) + toHex(bytes[3]),
|
||||
toHex(bytes[4]) + toHex(bytes[5]),
|
||||
toHex(bytes[6]) + toHex(bytes[7]),
|
||||
toHex(bytes[8]) + toHex(bytes[9]),
|
||||
toHex(bytes[10]) +
|
||||
toHex(bytes[11]) +
|
||||
toHex(bytes[12]) +
|
||||
toHex(bytes[13]) +
|
||||
toHex(bytes[14]) +
|
||||
toHex(bytes[15]),
|
||||
].join("-");
|
||||
} else {
|
||||
// If crypto is not available, fallback to Math.random based implementation
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
61
frontend/src/utils/sseManager.ts
Normal file
61
frontend/src/utils/sseManager.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
class SSEManager {
|
||||
private clients: Map<string, ReadableStreamDefaultController> = new Map();
|
||||
|
||||
addClient(id: string, controller: ReadableStreamDefaultController) {
|
||||
this.clients.set(id, controller);
|
||||
console.log(`Client ${id} added. Total clients: ${this.clients.size}`);
|
||||
}
|
||||
|
||||
removeClient(id: string) {
|
||||
const removed = this.clients.delete(id);
|
||||
console.log(
|
||||
`Client ${id} removed: ${removed}. Total clients: ${this.clients.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
broadcastToClients(data: any) {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
const clientsToRemove: string[] = [];
|
||||
|
||||
this.clients.forEach((controller, id) => {
|
||||
try {
|
||||
controller.enqueue(message);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error sending message to client ${id}, marking for removal:`,
|
||||
error,
|
||||
);
|
||||
clientsToRemove.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up dead connections
|
||||
clientsToRemove.forEach((id) => this.clients.delete(id));
|
||||
|
||||
if (clientsToRemove.length > 0) {
|
||||
console.log(
|
||||
`Removed ${clientsToRemove.length} dead connections. Remaining: ${this.clients.size}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getClientCount() {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
getAllClientIds() {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// Create a global singleton instance
|
||||
const globalForSSE = globalThis as unknown as {
|
||||
sseManager: SSEManager | undefined;
|
||||
};
|
||||
|
||||
// If the instance exists, use it. Otherwise, create a new one.
|
||||
export const sseManager = globalForSSE.sseManager ?? new SSEManager();
|
||||
|
||||
// Update the global reference
|
||||
globalForSSE.sseManager = sseManager;
|
Reference in New Issue
Block a user