first commit

This commit is contained in:
O K
2026-05-18 11:45:56 +03:00
commit d0156ad51c
20 changed files with 4324 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2303
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "server18004"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "server18004"
path = "src/main.rs"
[dependencies]
axum = "0.8.9"
base64 = "0.22.1"
clap = { version = "4.6.1", features = ["derive"] }
fast_qr = { version = "0.13.1", features = ["image"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.52.3", features = ["full"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
png = "0.17.16"
ravif = "0.11.11"
flate2 = "1.0.35"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
tempfile = "3"

272
README.md Normal file
View File

@@ -0,0 +1,272 @@
# server18004 🚀
A high-performance, production-ready QR code generation server written in Rust. It utilizes the [Axum](https://github.com/tokio-rs/axum) web framework and is highly optimized for maximum execution speed, minimal memory footprint, and low-latency rendering.
The server leverages a multi-port paradigm to separate public restricted QR rendering from internal/unrestricted administration and generation APIs.
---
## 🌟 Key Features
- **Multi-Port Isolation**:
- **Port 4081 (SVG)**: Restricted vector QR generation via catch-all/fallback handler.
- **Port 4082 (PNG)**: Restricted high-speed raster QR generation.
- **Port 4084 (AVIF)**: Restricted high-speed modern AVIF QR generation.
- **Port 4083 (API)**: Unrestricted administration API for domain management and custom generation.
- **High-Performance Graphic Encoders**:
- **PNG Generator**: Features custom 4x manual bit-multiplication scaling to a 1-bit Grayscale raster buffer, completely bypassing expensive image scaling filters. Leverages the standard `png` crate with `Compression::Fast` and `FilterType::NoFilter` for low-latency output.
- **AVIF Generator**: Leverages the speed of the `ravif` encoder operating in Speed 10 (Fastest) mode with Lossless quality, backed by a custom 4x RGBA scaling loop.
- **Smart Reverse-Proxy Support**:
- Resolves client hostnames by inspecting `X-Forwarded-Host`, `X-Real-Host`, and standard `Host` headers. Compatible with Nginx, HAProxy, and Apache reverse-proxy setups.
- Detects request schema (HTTP vs HTTPS) using the `X-Forwarded-Proto` header.
- **Domain Restricted Generation**:
- Fallback endpoints validate the requester's base domain against an allowlist.
- A designated default domain (e.g., `18004.pro`) is always allowed.
- **Runtime Domain Management**:
- Read-optimized, thread-safe memory storage (`Arc<RwLock<HashSet<String>>>`).
- Dynamic API additions/removals with immediate, safe file persistence.
---
## 🛠️ Port & Routing Architecture
```mermaid
graph TD
subgraph Restricted Ports (Allowlist Validation)
P1[Port 4081: SVG] -->|Catch-all| H_SVG[Handle SVG]
P2[Port 4082: PNG] -->|Catch-all| H_PNG[Handle PNG]
P4[Port 4084: AVIF] -->|Catch-all| H_AVIF[Handle AVIF]
end
subgraph Unrestricted Port
P3[Port 4083: API] -->|POST /generate| API_GEN[Custom QR Generation]
P3 -->|POST /domains/add| API_ADD[Add Domain]
P3 -->|POST /domains/remove| API_REM[Remove Domain]
P3 -->|GET /domains| API_LST[List Domains]
P3 -->|GET /health| API_HLT[Health Check]
end
```
### 1. Restricted Endpoints (Ports 4081, 4082, 4084)
These ports use fallback routes (catch-all). Any request path and query string will be automatically translated into a QR code pointing to the origin host & path.
* **How it works**:
1. The server reads the hostname from the incoming headers (checking `X-Forwarded-Host`, then `X-Real-Host`, then `Host`).
2. The first subdomain is stripped (e.g., `qr.example.com` becomes `example.com`).
3. The base domain is checked against the allowlist. If it's not present (and doesn't match the default domain), a `403 Forbidden` response is returned.
4. If allowed, it generates a QR code encoding `<scheme>://<base-domain><path><query>` (e.g., `https://example.com/some/path?ref=123`).
### 2. Unrestricted Endpoints (Port 4083)
A standard REST API for programmatic QR generation (without domain restrictions) and runtime control over the allowed domains.
---
## 📦 Installation & Setup
### Requirements
- Rust (Cargo) 1.70+
### Local Development
To run the server locally:
```bash
cargo run -- --config-path ./server.conf --domains-path ./domains.conf
```
### Production Deployment
The project comes with a robust installer (`install.sh`) that builds the release binary, creates a dedicated, unprivileged system user (`qrserver`), registers config templates, and configures a `systemd` service:
```bash
# 1. Build and install with root/sudo privileges
sudo ./install.sh
# 2. Start and enable the service
sudo systemctl enable --now server18004
# 3. Verify the status and view logs
sudo systemctl status server18004
sudo journalctl -u server18004 -f
```
---
## ⚙️ Configuration
### Server Configuration (`/etc/server18004/server.conf`)
An easy-to-use TOML file configuring server ports and the default domain:
```toml
# Port for restricted SVG QR code generation
port_svg = 4081
# Port for restricted PNG QR code generation
port_png = 4082
# Port for unrestricted JSON API
port_api = 4083
# Port for restricted AVIF QR code generation
port_avif = 4084
# Default domain that is always allowed on restricted ports
default_domain = "example.com"
```
### Domains Allowlist (`/etc/server18004/domains.conf`)
A simple line-based text file. Comments starting with `#` and blank lines are ignored.
```text
# Allowed base domains
example.com
mycompany.org
testdomain.dev
```
---
## 📖 Sample Usage & API Specifications
### 1. Fallback QR Code Generation (Restricted Ports)
When navigating to or requesting any path from the restricted ports, a QR code is returned directly.
#### SVG Format (Port 4081)
Assuming `example.com` is in the allowed domain list:
```bash
curl -i -H "Host: qr.example.com" http://localhost:4081/welcome?user=john
```
* **Result**: Returns an `image/svg+xml` payload containing a QR code that redirects to `https://example.com/welcome?user=john`.
#### PNG Format (Port 4082)
```bash
curl -i -H "Host: qr.example.com" http://localhost:4082/app/download
```
* **Result**: Returns an `image/png` payload containing a QR code encoding `https://example.com/app/download`.
#### AVIF Format (Port 4084)
```bash
curl -i -H "Host: qr.example.com" http://localhost:4084/promo
```
* **Result**: Returns an `image/avif` payload containing a QR code encoding `https://example.com/promo`.
---
### 2. Programmatic API (Port 4083)
The API port allows custom QR generation with flexible format settings and base64 options.
#### POST `/generate`
Generates a QR code for arbitrary text content.
##### Payload Schema:
```json
{
"text": "Your string here",
"ecl": "L", // Error Correction Level: L, M, Q, H (Default: L)
"format": "svg", // Output: svg, png, avif, base64, base64url (Default: svg)
"base64_source": "png", // For base64: png, svg, avif (Default: png)
"module_size": 10 // Optional custom scaling factor
}
```
##### Example 1: Direct PNG Output
```bash
curl -X POST http://localhost:4083/generate \
-H "Content-Type: application/json" \
-d '{
"text": "https://rust-lang.org",
"format": "png",
"ecl": "M"
}' --output qr.png
```
##### Example 2: Base64 Encoded JSON Response
```bash
curl -X POST http://localhost:4083/generate \
-H "Content-Type: application/json" \
-d '{
"text": "Hello World",
"format": "base64",
"base64_source": "svg"
}'
```
**Response JSON**:
```json
{
"data": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMyAzMyIgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4...",
"format": "base64",
"source": "svg"
}
```
---
### 3. Domain Management API (Port 4083)
#### GET `/domains`
Retrieves a list of all currently allowed domains.
```bash
curl http://localhost:4083/domains
```
**Response**:
```json
{
"success": true,
"message": "3 domain(s) configured",
"domains": ["example.com", "mycompany.org", "testdomain.dev"]
}
```
#### POST `/domains/add`
Adds a domain to the allowlist and immediately persists it to the configuration file on disk.
```bash
curl -X POST http://localhost:4083/domains/add \
-H "Content-Type: application/json" \
-d '{"domain": "newdomain.com"}'
```
**Response**:
```json
{
"success": true,
"message": "Domain 'newdomain.com' added successfully",
"domains": ["example.com", "mycompany.org", "testdomain.dev", "newdomain.com"]
}
```
#### POST `/domains/remove`
Removes a domain from the allowlist and updates the file on disk.
```bash
curl -X POST http://localhost:4083/domains/remove \
-H "Content-Type: application/json" \
-d '{"domain": "newdomain.com"}'
```
**Response**:
```json
{
"success": true,
"message": "Domain 'newdomain.com' removed successfully",
"domains": ["example.com", "mycompany.org", "testdomain.dev"]
}
```
#### GET `/health`
Simple health check endpoint for proxy checking or system monitors.
```bash
curl -i http://localhost:4083/health
```
**Response**:
```http
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
```
---
## 🧪 Testing
The codebase has a comprehensive suite of unit tests verifying domain manipulation, Host/IP extraction, and QR encoders.
To run the tests:
```bash
cargo test
```
```

View File

@@ -0,0 +1,15 @@
# Domain Allowlist for 18004 Server
# One domain per line. Lines starting with # are comments.
# These domains are allowed on the restricted SVG/PNG ports.
#
# The server strips the first subdomain from incoming requests:
# Request to qr.example.com -> checks "example.com" against this list
# Request to png.mysite.org -> checks "mysite.org" against this list
#
# Domains can also be added/removed at runtime via the API port:
# POST /domains/add {"domain": "newsite.com"}
# POST /domains/remove {"domain": "oldsite.com"}
# Runtime changes are persisted back to this file.
example.com
mysite.org

View File

@@ -0,0 +1,18 @@
# 18004 Server Configuration
# Copy this file to /etc/server18004/server.conf
# Port for restricted SVG QR code generation
port_svg = 4081
# Port for restricted PNG QR code generation
port_png = 4082
# Port for unrestricted JSON API (generate, domain management)
port_api = 4083
# Port for restricted AVIF QR code generation
port_avif = 4084
# Default domain that is always allowed on restricted ports
# (does not need to be in the domains allowlist)
default_domain = "example.com"

View File

@@ -0,0 +1,56 @@
[Unit]
Description=QR Code Generation Server (ISO 18004)
Documentation=https://github.com/your-org/server18004
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=qrserver
Group=qrserver
# Binary location (adjust after cargo build --release)
ExecStart=/usr/local/bin/server18004 \
--config-path /etc/server18004/server.conf \
--domains-path /etc/server18004/domains.conf
# Restart policy
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
# Environment
Environment=RUST_LOG=info
# Uncomment for debug logging:
# Environment=RUST_LOG=debug
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true
# Allow writing to config directory (for domain persistence)
ReadWritePaths=/etc/server18004
# Resource limits
LimitNOFILE=65536
LimitNPROC=4096
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=server18004
[Install]
WantedBy=multi-user.target

77
install.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
set -euo pipefail
# QR Server Installation Script
# Run as root or with sudo
BINARY_NAME="server18004"
INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc/server18004"
SERVICE_USER="qrserver"
SERVICE_FILE="/etc/systemd/system/server18004.service"
echo "=== server18004 Installer ==="
# 1. Build release binary
echo "[1/5] Building release binary..."
if command -v cargo &>/dev/null; then
cargo build --release
echo " ✓ Built target/release/${BINARY_NAME}"
elif [ -f "target/release/${BINARY_NAME}" ]; then
echo " ✓ Found existing binary in target/release/${BINARY_NAME}, skipping build"
else
echo " ✗ Error: 'cargo' not found and no existing binary in target/release/"
echo " Please run 'cargo build --release' as your normal user first,"
echo " then run this script with sudo."
exit 1
fi
# 2. Create service user
echo "[2/5] Creating service user..."
if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE_USER}"
echo " ✓ Created user ${SERVICE_USER}"
else
echo " ✓ User ${SERVICE_USER} already exists"
fi
# 3. Install binary
echo "[3/5] Installing binary..."
cp "target/release/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
chmod 755 "${INSTALL_DIR}/${BINARY_NAME}"
echo " ✓ Installed to ${INSTALL_DIR}/${BINARY_NAME}"
# 4. Install config files
echo "[4/5] Installing configuration..."
mkdir -p "${CONFIG_DIR}"
if [ ! -f "${CONFIG_DIR}/server.conf" ]; then
cp config/server.conf.example "${CONFIG_DIR}/server.conf"
echo " ✓ Installed server.conf"
else
echo " ⚠ server.conf already exists, skipping (see config/server.conf.example)"
fi
if [ ! -f "${CONFIG_DIR}/domains.conf" ]; then
cp config/domains.conf.example "${CONFIG_DIR}/domains.conf"
echo " ✓ Installed domains.conf"
else
echo " ⚠ domains.conf already exists, skipping (see config/domains.conf.example)"
fi
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}"
echo " ✓ Set ownership to ${SERVICE_USER}"
# 5. Install systemd service
echo "[5/5] Installing systemd service..."
cp config/server18004.service "${SERVICE_FILE}"
systemctl daemon-reload
echo " ✓ Service installed"
echo ""
echo "=== Installation Complete ==="
echo ""
echo "Next steps:"
echo " 1. Edit config: sudo nano ${CONFIG_DIR}/server.conf"
echo " 2. Edit domains: sudo nano ${CONFIG_DIR}/domains.conf"
echo " 3. Start service: sudo systemctl start server18004"
echo " 4. Enable on boot: sudo systemctl enable server18004"
echo " 5. Check status: sudo systemctl status server18004"
echo " 6. View logs: sudo journalctl -u server18004 -f"

87
src/config.rs Normal file
View File

@@ -0,0 +1,87 @@
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[arg(short, long, default_value = "/etc/server18004/server.conf")]
pub config_path: String,
#[arg(short, long, default_value = "/etc/server18004/domains.conf")]
pub domains_path: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerConfig {
pub port_svg: u16,
pub port_png: u16,
pub port_api: u16,
pub port_avif: u16,
pub default_domain: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port_svg: 4081,
port_png: 4082,
port_api: 4083,
port_avif: 4084,
default_domain: "example.com".to_string(),
}
}
}
impl ServerConfig {
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let config: ServerConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn generate_default(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let default_config = ServerConfig::default();
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(&default_config)?;
std::fs::write(path, content)?;
Ok(())
}
}
pub fn load_domains(path: &str) -> std::io::Result<HashSet<String>> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(HashSet::new());
}
Err(e) => return Err(e),
};
let reader = BufReader::new(file);
let mut domains = HashSet::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
domains.insert(trimmed.to_string());
}
}
Ok(domains)
}
pub fn save_domains(path: &str, domains: &HashSet<String>) -> std::io::Result<()> {
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = File::create(path)?;
for domain in domains {
writeln!(file, "{}", domain)?;
}
Ok(())
}

328
src/handlers/api.rs Normal file
View File

@@ -0,0 +1,328 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
Json,
};
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::qr;
use crate::state::AppState;
use fast_qr::ECL;
// ─── QR Generation Request ──────────────────────────────────────────────
#[derive(Deserialize)]
pub struct GenerateRequest {
/// The text content to encode in the QR code.
pub text: String,
/// Error correction level: "L", "M", "Q", or "H". Defaults to "L".
#[serde(default = "default_ecl")]
pub ecl: String,
/// Output format: "svg", "png", "base64", or "base64url". Defaults to "svg".
#[serde(default = "default_format")]
pub format: String,
/// For base64/base64url: the underlying image format to encode.
/// Either "png" or "svg". Defaults to "png".
#[serde(default = "default_base64_source")]
pub base64_source: String,
/// Module size (pixels per block). Defaults to 10.
pub module_size: Option<u32>,
}
fn default_ecl() -> String {
"L".to_string()
}
fn default_format() -> String {
"svg".to_string()
}
fn default_base64_source() -> String {
"png".to_string()
}
#[derive(Serialize)]
pub struct Base64Response {
pub data: String,
pub format: String,
pub source: String,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
/// POST /generate — Generate a custom QR code.
pub async fn generate_qr(
Json(req): Json<GenerateRequest>,
) -> impl IntoResponse {
let ecl = qr::parse_ecl(&req.ecl);
match req.format.to_lowercase().as_str() {
"svg" => match qr::generate_svg(&req.text, ecl, req.module_size) {
Ok(svg) => (
StatusCode::OK,
[("content-type", "image/svg+xml")],
svg,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
},
"png" => match qr::generate_png(&req.text, ecl, req.module_size) {
Ok(png) => (
StatusCode::OK,
[("content-type", "image/png")],
png,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
},
"avif" => match qr::generate_avif(&req.text, ecl, req.module_size) {
Ok(avif) => (
StatusCode::OK,
[("content-type", "image/avif")],
avif,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
},
"base64" => generate_base64(&req.text, ecl, &req.base64_source, req.module_size, false),
"base64url" => generate_base64(&req.text, ecl, &req.base64_source, req.module_size, true),
other => (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Unknown format '{}'. Use: svg, png, base64, base64url", other),
}),
)
.into_response(),
}
}
fn generate_base64(
text: &str,
ecl: ECL,
source: &str,
module_size: Option<u32>,
url_safe: bool,
) -> axum::response::Response {
let (data_bytes, source_name) = match source.to_lowercase().as_str() {
"svg" => match qr::generate_svg(text, ecl, module_size) {
Ok(svg) => (svg.into_bytes(), "svg"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response();
}
},
"avif" => match qr::generate_avif(text, ecl, module_size) {
Ok(avif) => (avif, "avif"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response();
}
},
_ => match qr::generate_png(text, ecl, module_size) {
Ok(png) => (png, "png"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response();
}
},
};
let encoded = if url_safe {
general_purpose::URL_SAFE_NO_PAD.encode(&data_bytes)
} else {
general_purpose::STANDARD.encode(&data_bytes)
};
let format_name = if url_safe { "base64url" } else { "base64" };
(
StatusCode::OK,
Json(Base64Response {
data: encoded,
format: format_name.to_string(),
source: source_name.to_string(),
}),
)
.into_response()
}
// ─── Domain Management ──────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct DomainRequest {
pub domain: String,
}
#[derive(Serialize)]
pub struct DomainResponse {
pub success: bool,
pub message: String,
pub domains: Vec<String>,
}
/// POST /domains/add — Add a domain to the allowlist and persist to disk.
pub async fn add_domain(
State(state): State<AppState>,
Json(req): Json<DomainRequest>,
) -> impl IntoResponse {
let domain = req.domain.trim().to_lowercase();
if domain.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(DomainResponse {
success: false,
message: "Domain cannot be empty".to_string(),
domains: vec![],
}),
);
}
// Safety: DNS max length is 253. Only allow valid domain characters to prevent injection/file corruption.
if domain.len() > 253 || !domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
return (
StatusCode::BAD_REQUEST,
Json(DomainResponse {
success: false,
message: "Invalid domain format (ASCII alphanumeric, dots, and hyphens only)".to_string(),
domains: vec![],
}),
);
}
let mut domains = state.domains.write().await;
let was_new = domains.insert(domain.clone());
// Persist to disk
if let Err(e) = config::save_domains(&state.domains_path, &domains) {
tracing::error!(error = %e, "Failed to persist domains to disk");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(DomainResponse {
success: false,
message: format!("Domain added to memory but failed to persist: {}", e),
domains: domains.iter().cloned().collect(),
}),
);
}
let message = if was_new {
format!("Domain '{}' added successfully", domain)
} else {
format!("Domain '{}' was already in the list", domain)
};
tracing::info!(%domain, "Domain added to allowlist");
(
StatusCode::OK,
Json(DomainResponse {
success: true,
message,
domains: domains.iter().cloned().collect(),
}),
)
}
/// POST /domains/remove — Remove a domain from the allowlist and persist to disk.
pub async fn remove_domain(
State(state): State<AppState>,
Json(req): Json<DomainRequest>,
) -> impl IntoResponse {
let domain = req.domain.trim().to_lowercase();
if domain.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(DomainResponse {
success: false,
message: "Domain cannot be empty".to_string(),
domains: vec![],
}),
);
}
let mut domains = state.domains.write().await;
let was_present = domains.remove(&domain);
// Persist to disk
if let Err(e) = config::save_domains(&state.domains_path, &domains) {
tracing::error!(error = %e, "Failed to persist domains to disk");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(DomainResponse {
success: false,
message: format!("Domain removed from memory but failed to persist: {}", e),
domains: domains.iter().cloned().collect(),
}),
);
}
let message = if was_present {
format!("Domain '{}' removed successfully", domain)
} else {
format!("Domain '{}' was not in the list", domain)
};
tracing::info!(%domain, "Domain removed from allowlist");
(
StatusCode::OK,
Json(DomainResponse {
success: true,
message,
domains: domains.iter().cloned().collect(),
}),
)
}
/// GET /domains — List all currently allowed domains.
pub async fn list_domains(
State(state): State<AppState>,
) -> impl IntoResponse {
let domains = state.domains.read().await;
let list: Vec<String> = domains.iter().cloned().collect();
(
StatusCode::OK,
Json(DomainResponse {
success: true,
message: format!("{} domain(s) configured", list.len()),
domains: list,
}),
)
}
/// GET /health — Simple health check endpoint.
pub async fn health() -> impl IntoResponse {
(StatusCode::OK, "OK")
}

2
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod restricted;
pub mod api;

252
src/handlers/restricted.rs Normal file
View File

@@ -0,0 +1,252 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode, Uri},
response::IntoResponse,
};
use crate::qr;
use crate::state::AppState;
use fast_qr::ECL;
/// Extracts the effective host from proxy headers.
/// Priority: X-Forwarded-Host > X-Real-Host > Host header.
/// Compatible with HAProxy and Apache reverse proxy setups.
fn extract_host(headers: &HeaderMap) -> Option<String> {
// X-Forwarded-Host may contain multiple hosts (comma-separated), take the first
if let Some(val) = headers.get("x-forwarded-host") {
if let Ok(s) = val.to_str() {
let host = s.split(',').next().unwrap_or("").trim();
if !host.is_empty() {
// Strip port if present
return Some(strip_port(host).to_lowercase());
}
}
}
// X-Real-Host (sometimes used by nginx/haproxy)
if let Some(val) = headers.get("x-real-host") {
if let Ok(s) = val.to_str() {
let host = s.trim();
if !host.is_empty() {
return Some(strip_port(host).to_lowercase());
}
}
}
// Standard Host header
if let Some(val) = headers.get("host") {
if let Ok(s) = val.to_str() {
let host = s.trim();
if !host.is_empty() {
return Some(strip_port(host).to_lowercase());
}
}
}
None
}
/// Strips port number from a host string (e.g., "example.com:8080" -> "example.com", "[::1]:8080" -> "[::1]").
fn strip_port(host: &str) -> &str {
if let Some(idx) = host.rfind(']') {
// It's an IPv6 address, return the part inside and including brackets
return &host[..=idx];
}
// For regular hostnames, split at the first colon
host.split(':').next().unwrap_or(host)
}
/// Strips the first subdomain from a hostname.
/// e.g., "qr.example.com" -> "example.com"
/// e.g., "sub.deep.example.com" -> "deep.example.com"
/// If there's no subdomain (e.g., "example.com"), returns as-is.
fn strip_first_subdomain(host: &str) -> &str {
match host.find('.') {
Some(idx) => &host[idx + 1..],
None => host,
}
}
/// Constructs the URL to encode in the QR code.
/// Uses X-Forwarded-Proto to detect scheme (defaults to https).
fn build_qr_url(headers: &HeaderMap, host: &str, uri: &Uri) -> String {
let scheme = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("https").trim())
.unwrap_or("https");
let base_domain = strip_first_subdomain(host);
let path = uri.path();
let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default();
format!("{}://{}{}{}", scheme, base_domain, path, query)
}
/// Handler for Port 1 (restricted, SVG output).
pub async fn handle_svg(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
handle_restricted(state, headers, uri, OutputFormat::Svg).await
}
/// Handler for Port 2 (restricted, PNG output).
pub async fn handle_png(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
handle_restricted(state, headers, uri, OutputFormat::Png).await
}
/// Handler for Port 4 (restricted, AVIF output).
pub async fn handle_avif(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
handle_restricted(state, headers, uri, OutputFormat::Avif).await
}
enum OutputFormat {
Svg,
Png,
Avif,
}
async fn handle_restricted(
state: AppState,
headers: HeaderMap,
uri: Uri,
format: OutputFormat,
) -> axum::response::Response {
// 1. Extract host
let host = match extract_host(&headers) {
Some(h) => h,
None => {
return (
StatusCode::BAD_REQUEST,
"Missing Host header",
)
.into_response();
}
};
// 2. Determine the base domain (strip first subdomain)
let base_domain = strip_first_subdomain(&host).to_string();
// 3. Check domain against allowlist
{
let domains = state.domains.read().await;
if !domains.contains(&base_domain) && base_domain != state.default_domain {
return (
StatusCode::FORBIDDEN,
format!("Domain '{}' is not in the allowed list", base_domain),
)
.into_response();
}
}
// 4. Build the URL to encode
let qr_url = build_qr_url(&headers, &host, &uri);
tracing::debug!(host = %host, base_domain = %base_domain, qr_url = %qr_url, "Generating QR code");
// 5. Generate QR code in the requested format
match format {
OutputFormat::Svg => match qr::generate_svg(&qr_url, ECL::L, None) {
Ok(svg) => (
StatusCode::OK,
[
("content-type", "image/svg+xml"),
("cache-control", "public, max-age=86400"),
],
svg,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "SVG generation failed");
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
}
},
OutputFormat::Png => match qr::generate_png(&qr_url, ECL::L, None) {
Ok(png) => (
StatusCode::OK,
[
("content-type", "image/png"),
("cache-control", "public, max-age=86400"),
],
png,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "PNG generation failed");
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
}
},
OutputFormat::Avif => match qr::generate_avif(&qr_url, ECL::L, None) {
Ok(avif) => (
StatusCode::OK,
[
("content-type", "image/avif"),
("cache-control", "public, max-age=86400"),
],
avif,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "AVIF generation failed");
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_port() {
assert_eq!(strip_port("example.com:8080"), "example.com");
assert_eq!(strip_port("example.com"), "example.com");
assert_eq!(strip_port("[::1]:8080"), "[::1]");
}
#[test]
fn test_strip_first_subdomain() {
assert_eq!(strip_first_subdomain("qr.example.com"), "example.com");
assert_eq!(
strip_first_subdomain("a.b.example.com"),
"b.example.com"
);
assert_eq!(strip_first_subdomain("example.com"), "com");
assert_eq!(strip_first_subdomain("localhost"), "localhost");
}
#[test]
fn test_extract_host_x_forwarded() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-host", "qr.example.com:443".parse().unwrap());
headers.insert("host", "internal.server:8080".parse().unwrap());
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
}
#[test]
fn test_extract_host_multiple_forwarded() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-host",
"qr.example.com, proxy.internal".parse().unwrap(),
);
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
}
#[test]
fn test_extract_host_fallback() {
let mut headers = HeaderMap::new();
headers.insert("host", "qr.example.com".parse().unwrap());
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
}
}

4
src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod handlers;
pub mod qr;
pub mod state;

107
src/main.rs Normal file
View File

@@ -0,0 +1,107 @@
use axum::{routing::{get, post}, Router};
use clap::Parser;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
use server18004::config::{self, Cli, ServerConfig};
use server18004::handlers;
use server18004::state::AppState;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
// Parse CLI args
let cli = Cli::parse();
// Load or generate server config
let server_config = match ServerConfig::load(&cli.config_path) {
Ok(cfg) => {
tracing::info!(path = %cli.config_path, "Loaded server configuration");
cfg
}
Err(e) => {
tracing::warn!(
error = %e,
path = %cli.config_path,
"Failed to load config, generating default"
);
ServerConfig::generate_default(&cli.config_path)?;
ServerConfig::load(&cli.config_path)?
}
};
// Load domains
let domains = config::load_domains(&cli.domains_path)?;
tracing::info!(count = domains.len(), path = %cli.domains_path, "Loaded domain allowlist");
// Build shared state
let app_state = AppState::new(
domains,
cli.domains_path.clone(),
server_config.default_domain.clone(),
);
// ── Port 1: Restricted SVG ──────────────────────────────────────────
let svg_state = app_state.clone();
let svg_router = Router::new()
.fallback(handlers::restricted::handle_svg)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
.with_state(svg_state);
// ── Port 2: Restricted PNG ──────────────────────────────────────────
let png_state = app_state.clone();
let png_router = Router::new()
.fallback(handlers::restricted::handle_png)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
.with_state(png_state);
// ── Port 4: Restricted AVIF ─────────────────────────────────────────
let avif_state = app_state.clone();
let avif_router = Router::new()
.fallback(handlers::restricted::handle_avif)
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
.with_state(avif_state);
// ── Port 3: Unrestricted API ────────────────────────────────────────
let api_state = app_state.clone();
let api_router = Router::new()
.route("/generate", post(handlers::api::generate_qr))
.route("/domains/add", post(handlers::api::add_domain))
.route("/domains/remove", post(handlers::api::remove_domain))
.route("/domains", get(handlers::api::list_domains))
.route("/health", get(handlers::api::health))
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
.with_state(api_state);
// ── Bind and serve ──────────────────────────────────────────────────
let addr_svg = SocketAddr::from(([0, 0, 0, 0], server_config.port_svg));
let addr_png = SocketAddr::from(([0, 0, 0, 0], server_config.port_png));
let addr_api = SocketAddr::from(([0, 0, 0, 0], server_config.port_api));
let addr_avif = SocketAddr::from(([0, 0, 0, 0], server_config.port_avif));
let listener_svg = TcpListener::bind(addr_svg).await?;
let listener_png = TcpListener::bind(addr_png).await?;
let listener_api = TcpListener::bind(addr_api).await?;
let listener_avif = TcpListener::bind(addr_avif).await?;
tracing::info!(addr = %addr_svg, "Starting restricted SVG server");
tracing::info!(addr = %addr_png, "Starting restricted PNG server");
tracing::info!(addr = %addr_api, "Starting unrestricted API server");
tracing::info!(addr = %addr_avif, "Starting restricted AVIF server");
let svc_svg = axum::serve(listener_svg, svg_router);
let svc_png = axum::serve(listener_png, png_router);
let svc_api = axum::serve(listener_api, api_router);
let svc_avif = axum::serve(listener_avif, avif_router);
tokio::try_join!(svc_svg, svc_png, svc_api, svc_avif)?;
Ok(())
}

190
src/qr.rs Normal file
View File

@@ -0,0 +1,190 @@
use fast_qr::ECL;
use fast_qr::qr::QRBuilder;
use png::{ColorType, BitDepth, Compression, FilterType};
use ravif::{RGBA8, Img, ColorModel};
use std::io::Cursor;
/// Maximum characters in a QR code (theoretical limit for Version 40 is 4,296 alphanumeric).
pub const MAX_CONTENT_LEN: usize = 4296;
pub fn generate_svg(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<String, String> {
if content.len() > MAX_CONTENT_LEN {
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
}
let qrcode = QRBuilder::new(content)
.ecl(ecc)
.build()
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
// SVG is naturally scalable, we stick to the default vector output for speed.
let svg = fast_qr::convert::svg::SvgBuilder::default()
.to_str(&qrcode);
Ok(svg)
}
/// Highly optimized PNG generation:
/// - 4x manual bit-multiplication loop
/// - 1-bit Grayscale (binary)
/// - Fast compression, No filtering
pub fn generate_png(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<Vec<u8>, String> {
if content.len() > MAX_CONTENT_LEN {
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
}
let qrcode = QRBuilder::new(content)
.ecl(ecc)
.build()
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
let qr_size = qrcode.size;
let padding = 4; // Standard QR quiet zone (4 modules)
let total_modules = qr_size + (padding * 2);
let img_size = total_modules * 4;
let row_bytes = (img_size + 7) / 8;
// Initialize with 0xFF (white)
let mut buffer = vec![0xFFu8; row_bytes * img_size];
// Fast manual 4x scaling loop with padding offset
// Since PNG 1-bit Grayscale: 0 = black, 1 = white.
for y in 0..qr_size {
let row_offset = y * qr_size;
// The QR modules start at 'padding' modules from the top/left
let py_start = (y + padding) * 4;
for x in 0..qr_size {
if qrcode.data[row_offset + x].0 & 1 != 0 {
let px_start = (x + padding) * 4;
// Set 4x4 block to black (0)
for dy in 0..4 {
let py = py_start + dy;
let img_row_start = py * row_bytes;
for dx in 0..4 {
let px = px_start + dx;
let byte_idx = img_row_start + (px / 8);
let bit_mask = !(1 << (7 - (px % 8)));
buffer[byte_idx] &= bit_mask;
}
}
}
}
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(Cursor::new(&mut out), img_size as u32, img_size as u32);
encoder.set_color(ColorType::Grayscale);
encoder.set_depth(BitDepth::One);
encoder.set_compression(Compression::Fast);
encoder.set_filter(FilterType::NoFilter);
let mut writer = encoder.write_header()
.map_err(|e| format!("Failed to write PNG header: {}", e))?;
writer.write_image_data(&buffer)
.map_err(|e| format!("Failed to write PNG data: {}", e))?;
}
Ok(out)
}
/// Optimized AVIF generation:
/// - Manual 4x pixel multiplication (RGBA8 for ravif compatibility)
/// - Lossless mode (Quality 100)
/// - Maximum encoding speed (Speed 10)
pub fn generate_avif(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<Vec<u8>, String> {
if content.len() > MAX_CONTENT_LEN {
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
}
let qrcode = QRBuilder::new(content)
.ecl(ecc)
.build()
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
let qr_size = qrcode.size;
let padding = 4; // Standard quiet zone
let total_modules = qr_size + (padding * 2);
let img_size = total_modules * 4;
// ravif takes RGBA8. We fill with white.
let mut pixels = vec![RGBA8::new(255, 255, 255, 255); img_size * img_size];
// Fast manual 4x scaling loop with padding offset
for y in 0..qr_size {
let qr_row_offset = y * qr_size;
let py_start = (y + padding) * 4;
for x in 0..qr_size {
if qrcode.data[qr_row_offset + x].0 & 1 != 0 {
let px_start = (x + padding) * 4;
let black = RGBA8::new(0, 0, 0, 255);
for dy in 0..4 {
let py = py_start + dy;
let img_row_offset = py * img_size;
for dx in 0..4 {
let px = px_start + dx;
pixels[img_row_offset + px] = black;
}
}
}
}
}
let img = Img::new(&pixels[..], img_size, img_size);
let encoded = ravif::Encoder::new()
.with_quality(100.0)
.with_speed(10)
.with_internal_color_model(ColorModel::RGB)
.encode_rgba(img)
.map_err(|e| format!("AVIF encoding failed: {}", e))?;
Ok(encoded.avif_file)
}
pub fn parse_ecl(ecl_str: &str) -> ECL {
match ecl_str.to_uppercase().as_str() {
"L" => ECL::L,
"M" => ECL::M,
"Q" => ECL::Q,
"H" => ECL::H,
_ => ECL::L,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ecl() {
assert!(matches!(parse_ecl("L"), ECL::L));
assert!(matches!(parse_ecl("m"), ECL::M));
assert!(matches!(parse_ecl("q"), ECL::Q));
assert!(matches!(parse_ecl("h"), ECL::H));
assert!(matches!(parse_ecl("invalid"), ECL::L));
}
#[test]
fn test_generate_svg_returns_valid_svg() {
let svg = generate_svg("https://example.com", ECL::L, None).unwrap();
assert!(svg.contains("<svg"), "Output should contain SVG tag");
assert!(svg.contains("</svg>"), "Output should contain closing SVG tag");
}
#[test]
fn test_generate_png_returns_valid_png() {
let png = generate_png("https://example.com", ECL::L, None).unwrap();
// PNG files start with the magic bytes: 0x89 P N G
assert!(png.len() > 8, "PNG should have content");
assert_eq!(&png[0..4], &[0x89, 0x50, 0x4E, 0x47], "Should have PNG magic bytes");
}
#[test]
fn test_generate_avif_returns_valid_avif() {
let avif = generate_avif("https://example.com", ECL::L, None).unwrap();
assert!(avif.len() > 16, "AVIF should have content");
// AVIF files usually have "ftypavif"
assert!(avif.windows(8).any(|w| w == b"ftypavif"), "Should have AVIF ftyp");
}
}

24
src/state.rs Normal file
View File

@@ -0,0 +1,24 @@
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Shared application state accessible by all server instances.
#[derive(Clone)]
pub struct AppState {
/// Thread-safe, read-optimized set of allowed domains.
pub domains: Arc<RwLock<HashSet<String>>>,
/// Path to the domains config file on disk (for persistence).
pub domains_path: String,
/// The default domain from the server config.
pub default_domain: String,
}
impl AppState {
pub fn new(domains: HashSet<String>, domains_path: String, default_domain: String) -> Self {
Self {
domains: Arc::new(RwLock::new(domains)),
domains_path,
default_domain,
}
}
}

23
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,23 @@
use std::collections::HashSet;
use server18004::state::AppState;
/// Creates a test AppState with the given allowed domains.
/// Uses a temp directory for the domains file so tests don't interfere.
pub fn test_state(domains: &[&str]) -> AppState {
let domain_set: HashSet<String> = domains.iter().map(|s| s.to_string()).collect();
// Use a temp file for domain persistence during tests
let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let domains_path = tmp_dir
.path()
.join("domains.conf")
.to_str()
.unwrap()
.to_string();
// We need to leak the TempDir so it doesn't get cleaned up during the test
// (the test functions are short-lived, this is fine for testing)
std::mem::forget(tmp_dir);
AppState::new(domain_set, domains_path, "test-default.com".to_string())
}

View File

@@ -0,0 +1,3 @@
GET http://127.0.0.1:4083/domains HTTP/1.1
content-type: application/json

530
tests/integration_test.rs Normal file
View File

@@ -0,0 +1,530 @@
use axum::{
body::Body,
http::{Request, StatusCode},
routing::{get, post},
Router,
};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::ServiceExt; // for oneshot
mod common;
use common::test_state;
// ─── Restricted SVG Server Tests ────────────────────────────────────────
fn svg_app(state: server18004::state::AppState) -> Router {
Router::new()
.fallback(server18004::handlers::restricted::handle_svg)
.with_state(state)
}
fn png_app(state: server18004::state::AppState) -> Router {
Router::new()
.fallback(server18004::handlers::restricted::handle_png)
.with_state(state)
}
fn api_app(state: server18004::state::AppState) -> Router {
Router::new()
.route("/generate", post(server18004::handlers::api::generate_qr))
.route("/domains/add", post(server18004::handlers::api::add_domain))
.route("/domains/remove", post(server18004::handlers::api::remove_domain))
.route("/domains", get(server18004::handlers::api::list_domains))
.route("/health", get(server18004::handlers::api::health))
.with_state(state)
}
#[tokio::test]
async fn test_svg_allowed_domain() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/some/path")
.header("host", "qr.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"image/svg+xml"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
let svg = String::from_utf8(body.to_vec()).unwrap();
assert!(svg.contains("<svg"), "Response should contain SVG content");
}
#[tokio::test]
async fn test_svg_forbidden_domain() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.header("host", "qr.forbidden.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_svg_missing_host() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_svg_x_forwarded_host() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/path")
.header("x-forwarded-host", "qr.example.com")
.header("host", "internal:8080")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_svg_default_domain() {
// The default domain "test-default.com" should always be allowed
let state = test_state(&[]); // empty allowlist
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.header("host", "qr.test-default.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
// ─── Restricted PNG Server Tests ────────────────────────────────────────
#[tokio::test]
async fn test_png_allowed_domain() {
let state = test_state(&["example.com"]);
let app = png_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.header("host", "qr.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"image/png"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(body.len() > 8, "PNG should have content");
assert_eq!(&body[0..4], &[0x89, 0x50, 0x4E, 0x47], "Should have PNG magic bytes");
}
#[tokio::test]
async fn test_png_forbidden_domain() {
let state = test_state(&["example.com"]);
let app = png_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.header("host", "qr.evil.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ─── API Server Tests ───────────────────────────────────────────────────
#[tokio::test]
async fn test_health_endpoint() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_generate_svg() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"text": "https://example.com",
"format": "svg"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"image/svg+xml"
);
}
#[tokio::test]
async fn test_generate_png() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"text": "https://example.com",
"format": "png",
"ecl": "H"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"image/png"
);
}
#[tokio::test]
async fn test_generate_base64() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"text": "https://example.com",
"format": "base64",
"base64_source": "png"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["format"], "base64");
assert_eq!(json["source"], "png");
assert!(!json["data"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn test_generate_base64url_svg_source() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"text": "hello world",
"format": "base64url",
"base64_source": "svg"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["format"], "base64url");
assert_eq!(json["source"], "svg");
// base64url should not contain + or / or =
let data = json["data"].as_str().unwrap();
assert!(!data.contains('+'), "base64url should not contain +");
assert!(!data.contains('/'), "base64url should not contain /");
}
#[tokio::test]
async fn test_generate_invalid_format() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/generate")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"text": "test",
"format": "bmp"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
// ─── Domain Management Tests ────────────────────────────────────────────
#[tokio::test]
async fn test_list_domains_empty() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/domains")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(json["domains"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_add_domain() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/domains/add")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"domain": "newdomain.com"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(json["domains"]
.as_array()
.unwrap()
.iter()
.any(|d| d == "newdomain.com"));
}
#[tokio::test]
async fn test_add_empty_domain() {
let state = test_state(&[]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/domains/add")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"domain": " "
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_remove_domain() {
let state = test_state(&["example.com", "test.com"]);
let app = api_app(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/domains/remove")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({
"domain": "example.com"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(!json["domains"]
.as_array()
.unwrap()
.iter()
.any(|d| d == "example.com"));
}
#[tokio::test]
async fn test_cache_control_headers() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/test")
.header("host", "qr.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("cache-control").unwrap(),
"public, max-age=86400"
);
}
#[tokio::test]
async fn test_x_forwarded_proto() {
let state = test_state(&["example.com"]);
let app = svg_app(state);
let response = app
.oneshot(
Request::builder()
.uri("/mypage?q=1")
.header("host", "qr.example.com")
.header("x-forwarded-proto", "http")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// The QR code should encode http://example.com/mypage?q=1
let body = response.into_body().collect().await.unwrap().to_bytes();
let svg = String::from_utf8(body.to_vec()).unwrap();
assert!(svg.contains("<svg"), "Response should be SVG");
}

4
tests/test_health.http Normal file
View File

@@ -0,0 +1,4 @@
POST http://127.0.0.1:4083/domains/add HTTP/1.1
content-type: application/json
{"domain": "newdomain.com"}