first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2303
Cargo.lock
generated
Normal file
2303
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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
272
README.md
Normal 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
|
||||
```
|
||||
```
|
||||
15
config/domains.conf.example
Normal file
15
config/domains.conf.example
Normal 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
|
||||
18
config/server.conf.example
Normal file
18
config/server.conf.example
Normal 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"
|
||||
56
config/server18004.service
Normal file
56
config/server18004.service
Normal 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
77
install.sh
Executable 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
87
src/config.rs
Normal 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
328
src/handlers/api.rs
Normal 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
2
src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod restricted;
|
||||
pub mod api;
|
||||
252
src/handlers/restricted.rs
Normal file
252
src/handlers/restricted.rs
Normal 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
4
src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod handlers;
|
||||
pub mod qr;
|
||||
pub mod state;
|
||||
107
src/main.rs
Normal file
107
src/main.rs
Normal 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
190
src/qr.rs
Normal 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
24
src/state.rs
Normal 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
23
tests/common/mod.rs
Normal 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())
|
||||
}
|
||||
3
tests/common/test_health.http
Normal file
3
tests/common/test_health.http
Normal 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
530
tests/integration_test.rs
Normal 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
4
tests/test_health.http
Normal file
@@ -0,0 +1,4 @@
|
||||
POST http://127.0.0.1:4083/domains/add HTTP/1.1
|
||||
content-type: application/json
|
||||
|
||||
{"domain": "newdomain.com"}
|
||||
Reference in New Issue
Block a user