Skip to content

Commit ef1e7a7

Browse files
Merge pull request #8 from etiennecollin/feat/kiosk-mode
Release 1.4.0
2 parents 1f534b4 + 9084d2e commit ef1e7a7

36 files changed

Lines changed: 1306 additions & 388 deletions

README.md

Lines changed: 66 additions & 27 deletions
Large diffs are not rendered by default.

backend/Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ axum = "0.8.4"
88
chrono = { version = "0.4.41" }
99
chrono-tz = "0.10.4"
1010
dotenvy = { version = "0.15.7", optional = true }
11+
percent-encoding = "2.3.2"
1112
reqwest = { version = "0.12.22", features = ["json", "rustls-tls"] }
1213
serde = { version = "1.0.219", features = ["derive"] }
1314
serde_json = "1.0.141"

backend/src/environment.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use std::{env, sync::OnceLock};
2+
3+
use chrono_tz::Tz;
4+
use tracing::{error, info};
5+
6+
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
7+
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
8+
const DEFAULT_UNIFI_SITE_ID: &str = "default";
9+
const DEEFAULT_ROLLING_VOUCHER_DURATION_MINUTES: u64 = 480;
10+
11+
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
12+
13+
#[derive(Debug, Clone)]
14+
pub struct Environment {
15+
pub unifi_controller_url: String,
16+
pub unifi_site_id: String,
17+
pub unifi_api_key: String,
18+
pub backend_bind_host: String,
19+
pub backend_bind_port: u16,
20+
pub rolling_voucher_duration_minutes: u64,
21+
pub unifi_has_valid_cert: bool,
22+
pub timezone: Tz,
23+
}
24+
25+
impl Environment {
26+
pub fn try_new() -> Result<Self, String> {
27+
#[cfg(feature = "dotenv")]
28+
dotenvy::dotenv().map_err(|e| format!("Failed to load .env file: {e}"))?;
29+
30+
let unifi_controller_url: String =
31+
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
32+
33+
if !unifi_controller_url.starts_with("http://")
34+
&& !unifi_controller_url.starts_with("https://")
35+
{
36+
return Err("UNIFI_CONTROLLER_URL must start with http:// or https://".to_string());
37+
}
38+
39+
let unifi_api_key: String =
40+
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
41+
let unifi_site_id: String =
42+
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
43+
44+
let backend_bind_host: String =
45+
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
46+
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
47+
Ok(port_str) => port_str
48+
.parse()
49+
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
50+
Err(_) => DEFAULT_BACKEND_BIND_PORT,
51+
};
52+
53+
let rolling_voucher_duration_minutes = match env::var("ROLLING_VOUCHER_DURATION_MINUTES") {
54+
Ok(val) => val
55+
.parse()
56+
.map_err(|e| format!("Invalid ROLLING_VOUCHER_DURATION_MINUTES: {e}"))?,
57+
Err(_) => DEEFAULT_ROLLING_VOUCHER_DURATION_MINUTES,
58+
};
59+
60+
let unifi_has_valid_cert: bool = match env::var("UNIFI_HAS_VALID_CERT") {
61+
Ok(val) => {
62+
Self::parse_bool(&val).map_err(|e| format!("Invalid UNIFI_HAS_VALID_CERT: {e}"))?
63+
}
64+
Err(_) => true,
65+
};
66+
67+
let timezone: Tz = match env::var("TIMEZONE") {
68+
Ok(s) => match s.parse() {
69+
Ok(tz) => {
70+
info!("Using timezone: {}", s);
71+
tz
72+
}
73+
Err(_) => {
74+
error!("Using UTC, could not parse timezone: {}", s);
75+
Tz::UTC
76+
}
77+
},
78+
Err(_) => {
79+
info!("TIMEZONE environment variable not set, defaulting to UTC");
80+
Tz::UTC
81+
}
82+
};
83+
84+
Ok(Self {
85+
unifi_controller_url,
86+
unifi_site_id,
87+
unifi_api_key,
88+
backend_bind_host,
89+
backend_bind_port,
90+
rolling_voucher_duration_minutes,
91+
unifi_has_valid_cert,
92+
timezone,
93+
})
94+
}
95+
96+
fn parse_bool(s: &str) -> Result<bool, String> {
97+
match s.trim().to_lowercase().as_str() {
98+
"true" | "1" | "yes" => Ok(true),
99+
"false" | "0" | "no" => Ok(false),
100+
_ => Err(format!("Boolean value must be true or false, found: {s}")),
101+
}
102+
}
103+
}

backend/src/handlers.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
use crate::{models::*, unifi_api::*};
2-
use axum::{extract::Query, http::StatusCode, response::Json};
3-
use tracing::{debug, error};
1+
use axum::{
2+
extract::Query,
3+
http::{HeaderMap, StatusCode},
4+
response::Json,
5+
};
6+
use tracing::{debug, error, info};
7+
8+
use crate::{models::*, unifi_api::UNIFI_API};
49

510
pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusCode> {
611
debug!("Received request to get vouchers");
@@ -14,7 +19,20 @@ pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusC
1419
}
1520
}
1621

17-
pub async fn get_newest_voucher_handler() -> Result<Json<Option<Voucher>>, StatusCode> {
22+
pub async fn get_rolling_voucher_handler() -> Result<Json<Voucher>, StatusCode> {
23+
debug!("Received request to get rolling voucher");
24+
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
25+
match client.get_rolling_voucher().await {
26+
Ok(Some(voucher)) => Ok(Json(voucher)),
27+
Ok(None) => Err(StatusCode::NOT_FOUND),
28+
Err(e) => {
29+
error!("Failed to get rolling voucher: {}", e);
30+
Err(e)
31+
}
32+
}
33+
}
34+
35+
pub async fn get_newest_voucher_handler() -> Result<Json<Voucher>, StatusCode> {
1836
debug!("Received request to get newest voucher");
1937
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
2038
match client.get_newest_voucher().await {
@@ -54,6 +72,38 @@ pub async fn create_voucher_handler(
5472
}
5573
}
5674

75+
pub async fn create_rolling_voucher_handler(
76+
headers: HeaderMap,
77+
) -> Result<Json<Voucher>, StatusCode> {
78+
debug!("Received request to create voucher");
79+
80+
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
81+
82+
if let Some(forwarded) = headers.get("x-forwarded-for") {
83+
if let Ok(ip) = forwarded.to_str() {
84+
debug!("Client IP from x-forwarded-for: {}", ip);
85+
86+
// Check if user already rotated the rolling voucher
87+
if client.check_rolling_voucher_ip(ip).await? {
88+
info!("Rolling voucher already rotated for IP: {}", ip);
89+
return Err(StatusCode::FORBIDDEN);
90+
}
91+
92+
// Voucher rotation allowed, create a new rolling voucher
93+
match client.create_rolling_voucher(ip).await {
94+
Ok(response) => return Ok(Json(response)),
95+
Err(e) => {
96+
error!("Failed to create rolling voucher: {}", e);
97+
return Err(e);
98+
}
99+
}
100+
}
101+
}
102+
103+
error!("Invalid x-forwarded-for header");
104+
Err(StatusCode::BAD_REQUEST)
105+
}
106+
57107
pub async fn delete_selected_handler(
58108
Query(params): Query<DeleteRequest>,
59109
) -> Result<Json<DeleteResponse>, StatusCode> {
@@ -81,6 +131,18 @@ pub async fn delete_expired_handler() -> Result<Json<DeleteResponse>, StatusCode
81131
}
82132
}
83133

134+
pub async fn delete_expired_rolling_handler() -> Result<Json<DeleteResponse>, StatusCode> {
135+
debug!("Received request to delete expired rolling voucher");
136+
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
137+
match client.delete_expired_rolling_vouchers().await {
138+
Ok(response) => Ok(Json(response)),
139+
Err(e) => {
140+
error!("Failed to delete expired rolling voucher: {}", e);
141+
Err(e)
142+
}
143+
}
144+
}
145+
84146
pub async fn health_check_handler() -> Result<Json<HealthCheckResponse>, StatusCode> {
85147
debug!("Received health check request");
86148
let response = HealthCheckResponse {

backend/src/lib.rs

Lines changed: 2 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,5 @@
1-
use std::{env, sync::OnceLock};
2-
3-
use chrono_tz::Tz;
4-
use tracing::{error, info};
5-
1+
pub mod environment;
62
pub mod handlers;
73
pub mod models;
4+
pub mod tasks;
85
pub mod unifi_api;
9-
10-
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
11-
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
12-
const DEFAULT_UNIFI_SITE_ID: &str = "default";
13-
14-
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
15-
16-
#[derive(Debug, Clone)]
17-
pub struct Environment {
18-
pub unifi_controller_url: String,
19-
pub unifi_site_id: String,
20-
pub unifi_api_key: String,
21-
pub backend_bind_host: String,
22-
pub backend_bind_port: u16,
23-
pub unifi_has_valid_cert: bool,
24-
pub timezone: Tz,
25-
}
26-
27-
impl Environment {
28-
pub fn try_new() -> Result<Self, String> {
29-
#[cfg(feature = "dotenv")]
30-
dotenvy::dotenv().map_err(|e| format!("Failed to load .env file: {e}"))?;
31-
32-
let unifi_controller_url: String =
33-
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
34-
35-
if !unifi_controller_url.starts_with("http://")
36-
&& !unifi_controller_url.starts_with("https://")
37-
{
38-
return Err("UNIFI_CONTROLLER_URL must start with http:// or https://".to_string());
39-
}
40-
41-
let unifi_api_key: String =
42-
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
43-
let unifi_site_id: String =
44-
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
45-
46-
let backend_bind_host: String =
47-
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
48-
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
49-
Ok(port_str) => port_str
50-
.parse()
51-
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
52-
Err(_) => DEFAULT_BACKEND_BIND_PORT,
53-
};
54-
55-
let unifi_has_valid_cert: bool = match env::var("UNIFI_HAS_VALID_CERT") {
56-
Ok(val) => {
57-
Self::parse_bool(&val).map_err(|e| format!("Invalid UNIFI_HAS_VALID_CERT: {e}"))?
58-
}
59-
Err(_) => true,
60-
};
61-
62-
let timezone: Tz = match env::var("TIMEZONE") {
63-
Ok(s) => match s.parse() {
64-
Ok(tz) => {
65-
info!("Using timezone: {}", s);
66-
tz
67-
}
68-
Err(_) => {
69-
error!("Using UTC, could not parse timezone: {}", s);
70-
Tz::UTC
71-
}
72-
},
73-
Err(_) => {
74-
info!("TIMEZONE environment variable not set, defaulting to UTC");
75-
Tz::UTC
76-
}
77-
};
78-
79-
Ok(Self {
80-
unifi_controller_url,
81-
unifi_site_id,
82-
unifi_api_key,
83-
backend_bind_host,
84-
backend_bind_port,
85-
unifi_has_valid_cert,
86-
timezone,
87-
})
88-
}
89-
90-
fn parse_bool(s: &str) -> Result<bool, String> {
91-
match s.trim().to_lowercase().as_str() {
92-
"true" | "1" | "yes" => Ok(true),
93-
"false" | "0" | "no" => Ok(false),
94-
_ => Err(format!("Boolean value must be true or false, found: {s}")),
95-
}
96-
}
97-
}

0 commit comments

Comments
 (0)