28 Commits

Author SHA1 Message Date
etiennecollin
1e302e5c2c docs: replaced environment variable table 2025-09-07 20:20:37 -04:00
etiennecollin
d6e11f406c docs: improved readme security warnings 2025-09-07 19:05:59 -04:00
Etienne Collin
ef1e7a780e Merge pull request #8 from etiennecollin/feat/kiosk-mode
Release 1.4.0
2025-09-07 18:43:02 -04:00
etiennecollin
9084d2ef3e chore: updated compose for new recommended variables 2025-09-07 18:39:45 -04:00
etiennecollin
492e71fcc3 feat: restrict guest subnetwork access 2025-09-07 18:39:45 -04:00
etiennecollin
89a1421efb fix: ignore expired rolling vouchers on check 2025-09-07 17:17:35 -04:00
etiennecollin
a83be957df docs: updated readme with kiosk information 2025-09-07 17:09:41 -04:00
etiennecollin
74e824f834 refactor: cleanup of print page 2025-09-07 16:53:29 -04:00
etiennecollin
dacd6a9a5c refactor: voucher cards and voucher status 2025-09-07 15:54:24 -04:00
etiennecollin
b81efc3c87 style: removed interactive button text 2025-09-07 15:13:23 -04:00
etiennecollin
404d35b437 feat: improved welcome page 2025-09-07 15:10:32 -04:00
etiennecollin
fbff084818 fix: handle unavailable QR on kiosk page 2025-09-07 14:50:41 -04:00
etiennecollin
a8288d785d feat: added QR code to kiosk page 2025-09-07 14:37:44 -04:00
etiennecollin
d14e940563 refactor: made WiFi Qr code component modular 2025-09-07 14:34:51 -04:00
etiennecollin
14b3b408ea fix: some devices do not have the crypto.randomUUID() function 2025-09-07 14:10:26 -04:00
etiennecollin
7dd8218867 chore: renamed main <div> to <main> and minor tweaks 2025-09-07 13:27:14 -04:00
etiennecollin
c9e85d5172 fix: URL encoding for API request 2025-09-07 13:08:53 -04:00
etiennecollin
ce6c92edf4 feat: rolling vouchers daily purge 2025-09-07 13:08:53 -04:00
etiennecollin
486f384409 refactor: cleanup of backend 2025-09-07 11:13:05 -04:00
etiennecollin
04a6508554 fix: modal close button size and position 2025-09-07 02:54:06 -04:00
etiennecollin
f799a8575e chore: cleanup of tailwind utilities 2025-09-07 02:54:06 -04:00
etiennecollin
ee6b0d6109 fix: header was too wide on small screens 2025-09-07 02:54:06 -04:00
etiennecollin
b8a7ca808b fix: handle SSE reconnections 2025-09-07 02:54:06 -04:00
etiennecollin
562a314fc8 fix: updates work accross clients
Fixed issue where custom window events only worked within single browser
tabs. Implemented SSE with singleton pattern to broadcast updates across
all open tabs and users when voucher operations occur.

- Added SSE manager with globalThis singleton for cross-process
persistence
- Created server action to broadcast events via SSE after voucher
operations
- Updated client API calls to trigger server action notifications
- SSE automatically triggers existing vouchersUpdated window events
2025-09-07 02:54:06 -04:00
etiennecollin
4dddbe9b70 style: commenting and changed component names 2025-09-07 02:54:06 -04:00
etiennecollin
0b22b2277e feat: added frontend support for kiosk mode
- In the middleware, we pass the IP of the client updating the rolling
voucher to prevent other updates by reloading the page. In the future,
we should use the MAC address...
2025-09-07 02:54:06 -04:00
etiennecollin
7017308db9 chore: ignore runtime-config.js 2025-09-07 02:54:06 -04:00
etiennecollin
c639d7ce22 feat: added backend support for rolling vouchers 2025-09-07 02:54:06 -04:00
36 changed files with 1366 additions and 397 deletions

160
README.md
View File

@@ -3,7 +3,7 @@
[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/etiennecollin/unifi-voucher-manager?sort=semver&label=Version&logo=docker&color=blue) ![Docker Pulls](https://img.shields.io/docker/pulls/etiennecollin/unifi-voucher-manager?label=Pulls&logo=docker&color=blue)](https://hub.docker.com/r/etiennecollin/unifi-voucher-manager)
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/etiennecollin/unifi-voucher-manager/release_docker.yaml?label=Docker%20Build&logo=github) ![GitHub License](https://img.shields.io/github/license/etiennecollin/unifi-voucher-manager?label=License&logo=github&color=red)](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.
![WiFi Voucher Manager](./assets/view.png)
@@ -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 controllers 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 controllers 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
View File

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

View File

@@ -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
View 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}")),
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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
View 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),
};
}
}

View File

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

View File

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

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

View 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",
},
});
}

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View File

@@ -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 WiFi QR code"
title="Open WiFi QR code"
>
@@ -54,7 +69,7 @@ export default function Header() {
<ThemeSwitcher />
</div>
</div>
{showWifi && wifiConfig && (
{qrAvailable && showWifi && (
<WifiQrModal onClose={() => setShowWifi(false)} />
)}
</header>

View File

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

View File

@@ -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"
>
&times;
<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>

View File

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

View File

@@ -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">
WiFi 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 WiFi 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>
);

View File

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

View File

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

View File

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

View 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 WiFi credentials configured.</p>
)}
</div>
</div>
);
}

View File

@@ -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(() => {

View 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; // 85115% 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;
}

View File

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

View File

@@ -0,0 +1 @@
export type TriState = "loading" | "error" | "ok";

View File

@@ -0,0 +1,10 @@
"use server";
import { sseManager } from "./sseManager";
export async function notifyVouchersUpdated() {
sseManager.broadcastToClients({
type: "vouchersUpdated",
timestamp: Date.now(),
});
}

View File

@@ -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;
},
};

View File

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

View File

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

View 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;