feat(server): build axum web server for licensing, landing page, and updates
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# Stripe secret key (sk_test_... for development, sk_live_... for production)
|
||||||
|
STRIPE_SECRET_KEY=sk_test_placeholder
|
||||||
|
|
||||||
|
# Stripe Price ID for the CleanCopy product (price_...)
|
||||||
|
STRIPE_PRICE_ID=price_...
|
||||||
|
|
||||||
|
# Ed25519 private key in hex (64 characters)
|
||||||
|
# Generate with: cargo run --bin license_tool generate-keys production
|
||||||
|
SIGNING_KEY_HEX=
|
||||||
|
|
||||||
|
# SQLite database URL
|
||||||
|
DATABASE_URL=sqlite:/data/cleancopy.db?mode=rwc
|
||||||
|
|
||||||
|
# Directory containing release binaries (mounted into Docker)
|
||||||
|
RELEASE_DIR=/data/releases
|
||||||
|
|
||||||
|
# Directory containing static files (fonts, favicon, logo)
|
||||||
|
STATIC_DIR=./static
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Public URL of the server (used in checkout redirect links)
|
||||||
|
APP_URL=https://cleancopy.stevanfreeborn.com
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tab_spaces = 2
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "clean-copy-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
shared = { path = "../shared" }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "catch-panic"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
hex = "0.4"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
async-stripe = { version = "0.41.0", features = ["runtime-tokio-hyper-rustls"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
askama = "0.14"
|
||||||
|
askama_web = { version = "0.14", features = ["axum-0.8", "tracing-0.1"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wiremock = "0.6"
|
||||||
|
tempfile = "3"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM rust:bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY Cargo.lock licensing_profiles.json ./
|
||||||
|
RUN printf '[workspace]\nmembers = ["shared", "server"]\nresolver = "2"\n[workspace.package]\nedition = "2021"\nauthors = ["Stevan Freeborn"]\n[workspace.dependencies]\nserde = { version = "1", features = ["derive"] }\nserde_json = "1"\ned25519-dalek = "3.0.0"\nbase64 = "0.22.1"\nsha2 = "0.11.0"\nthiserror = "2"\nurl = "2.5.8"\nlog = "0.4"\n[profile.release]\nlto = true\ncodegen-units = 1\nstrip = "symbols"\nopt-level = "s"\npanic = "abort"\n' > Cargo.toml
|
||||||
|
COPY shared/ shared/
|
||||||
|
COPY server/ server/
|
||||||
|
RUN cargo build --release -p clean-copy-server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /app/target/release/clean-copy-server /usr/local/bin/server
|
||||||
|
COPY server/static/ /app/server/static/
|
||||||
|
VOLUME /data
|
||||||
|
ENV DATABASE_URL=sqlite:/data/cleancopy.db?mode=rwc
|
||||||
|
ENV RELEASE_DIR=/data/releases
|
||||||
|
ENV STATIC_DIR=/app/server/static
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["server"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# CleanCopy Server
|
||||||
|
|
||||||
|
Axum web backend for CleanCopy. Handles license activation, Stripe webhooks, release downloads, and the Tauri updater endpoint.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/` | Landing page |
|
||||||
|
| GET | `/docs` | Documentation |
|
||||||
|
| GET | `/docs/terms` | Terms of Service |
|
||||||
|
| GET | `/docs/refunds` | Refund Policy |
|
||||||
|
| GET/POST | `/api/activate` | License activation (Ed25519 signing) |
|
||||||
|
| POST | `/api/webhook` | Stripe webhook handler |
|
||||||
|
| GET | `/releases/download/{version}/{arch}` | Release binary download |
|
||||||
|
| GET | `/updates/{target}/{arch}/{version}.json` | Tauri updater manifest |
|
||||||
|
| GET | `/healthz` | Health check |
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p clean-copy-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Server listens on `0.0.0.0:3000`.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t clean-copy-server .
|
||||||
|
docker run -p 3000:3000 clean-copy-server
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
payment_id TEXT NOT NULL UNIQUE,
|
||||||
|
hwid TEXT NOT NULL,
|
||||||
|
device_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_devices INTEGER,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_licenses_payment_id ON licenses(payment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_licenses_hwid ON licenses(hwid);
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub stripe_secret_key: String,
|
||||||
|
pub stripe_price_id: String,
|
||||||
|
pub signing_key_hex: String,
|
||||||
|
pub database_url: String,
|
||||||
|
pub release_dir: String,
|
||||||
|
pub static_dir: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub app_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
load_dotenv();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
stripe_secret_key: env::var("STRIPE_SECRET_KEY")
|
||||||
|
.unwrap_or_else(|_| "sk_test_placeholder".to_string()),
|
||||||
|
stripe_price_id: env::var("STRIPE_PRICE_ID").unwrap_or_default(),
|
||||||
|
signing_key_hex: env::var("SIGNING_KEY_HEX").unwrap_or_else(|_| "0".repeat(64)),
|
||||||
|
database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()),
|
||||||
|
release_dir: env::var("RELEASE_DIR").unwrap_or_else(|_| "./releases".to_string()),
|
||||||
|
static_dir: env::var("STATIC_DIR")
|
||||||
|
.unwrap_or_else(|_| "./static".to_string()),
|
||||||
|
port: env::var("PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(3000),
|
||||||
|
app_url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_dotenv() {
|
||||||
|
if let Ok(cwd) = env::current_dir() {
|
||||||
|
let cwd_env = cwd.join(".env");
|
||||||
|
if cwd_env.exists() {
|
||||||
|
dotenvy::from_path(&cwd_env).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cwd_server_env = cwd.join("server").join(".env");
|
||||||
|
if cwd_server_env.exists() {
|
||||||
|
dotenvy::from_path(&cwd_server_env).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(exe) = env::current_exe() {
|
||||||
|
if let Some(exe_dir) = exe.parent() {
|
||||||
|
let mut dir = exe_dir.to_path_buf();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let env_path = dir.join(".env");
|
||||||
|
|
||||||
|
if env_path.exists() {
|
||||||
|
dotenvy::from_path(&env_path).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dir.pop() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_defaults() {
|
||||||
|
let config = Config {
|
||||||
|
stripe_secret_key: "sk_test_placeholder".to_string(),
|
||||||
|
stripe_price_id: String::new(),
|
||||||
|
signing_key_hex: "0".repeat(64),
|
||||||
|
database_url: "sqlite::memory:".to_string(),
|
||||||
|
release_dir: "./releases".to_string(),
|
||||||
|
static_dir: "./static".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
app_url: "http://localhost:3000".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.stripe_secret_key, "sk_test_placeholder");
|
||||||
|
assert!(config.stripe_price_id.is_empty());
|
||||||
|
assert_eq!(config.signing_key_hex, "0".repeat(64));
|
||||||
|
assert_eq!(config.database_url, "sqlite::memory:");
|
||||||
|
assert_eq!(config.release_dir, "./releases");
|
||||||
|
assert_eq!(config.static_dir, "./static");
|
||||||
|
assert_eq!(config.port, 3000);
|
||||||
|
assert_eq!(config.app_url, "http://localhost:3000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_custom_values() {
|
||||||
|
let config = Config {
|
||||||
|
stripe_secret_key: "sk_test_abc123".to_string(),
|
||||||
|
stripe_price_id: "price_123".to_string(),
|
||||||
|
signing_key_hex: "a".repeat(64),
|
||||||
|
database_url: "sqlite:./test.db".to_string(),
|
||||||
|
release_dir: "/data/releases".to_string(),
|
||||||
|
static_dir: "/app/server/static".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
app_url: "https://cleancopy.app".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.stripe_secret_key, "sk_test_abc123");
|
||||||
|
assert_eq!(config.stripe_price_id, "price_123");
|
||||||
|
assert_eq!(config.signing_key_hex, "a".repeat(64));
|
||||||
|
assert_eq!(config.database_url, "sqlite:./test.db");
|
||||||
|
assert_eq!(config.release_dir, "/data/releases");
|
||||||
|
assert_eq!(config.static_dir, "/app/server/static");
|
||||||
|
assert_eq!(config.port, 8080);
|
||||||
|
assert_eq!(config.app_url, "https://cleancopy.app");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_port_parse_invalid_falls_back_to_3000() {
|
||||||
|
let port: u16 = "not_a_number".parse().unwrap_or(3000);
|
||||||
|
assert_eq!(port, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub const PRODUCT_NAME: &str = "CleanCopy";
|
||||||
|
pub const COPYRIGHT_YEAR: &str = "2026";
|
||||||
|
pub const CONTACT_EMAIL: &str = "me@stevanfreeborn.com";
|
||||||
|
pub const UPDATED_DATE: &str = "July 2026";
|
||||||
|
pub const PRICE: &str = "$5";
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
|
||||||
|
use shared::types::LicensePayload;
|
||||||
|
|
||||||
|
pub fn load_signing_key(hex_str: &str) -> Result<SigningKey, String> {
|
||||||
|
let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid hex in signing key: {}", e))?;
|
||||||
|
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return Err(format!(
|
||||||
|
"Signing key must be exactly 32 bytes (64 hex characters), got {}",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key_bytes = [0u8; 32];
|
||||||
|
|
||||||
|
key_bytes.copy_from_slice(&bytes);
|
||||||
|
|
||||||
|
Ok(SigningKey::from_bytes(&key_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign_license(
|
||||||
|
signing_key: &SigningKey,
|
||||||
|
license_key: &str,
|
||||||
|
hwid: &str,
|
||||||
|
device_count: Option<u32>,
|
||||||
|
max_devices: Option<u32>,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let payload = LicensePayload {
|
||||||
|
product_id: "cleancopy".to_string(),
|
||||||
|
license_key: license_key.to_string(),
|
||||||
|
hwid: hwid.to_string(),
|
||||||
|
max_devices,
|
||||||
|
expires_at: expires_at.map(|s| s.to_string()),
|
||||||
|
device_count,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||||
|
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||||
|
|
||||||
|
let signature = signing_key.sign(payload_b64.as_bytes());
|
||||||
|
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||||
|
|
||||||
|
format!("{}.{}", payload_b64, signature_b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_license(
|
||||||
|
token: &str,
|
||||||
|
public_key_bytes: &[u8; 32],
|
||||||
|
current_hwid: &str,
|
||||||
|
) -> Result<LicensePayload, String> {
|
||||||
|
shared::licensing::verify_license_with_key(token, public_key_bytes, current_hwid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_verifying_key(signing_key: &SigningKey) -> VerifyingKey {
|
||||||
|
signing_key.verifying_key()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_signing_key() -> SigningKey {
|
||||||
|
let bytes = [42u8; 32];
|
||||||
|
|
||||||
|
SigningKey::from_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_signing_key_valid() {
|
||||||
|
let hex_str = "42".repeat(32);
|
||||||
|
|
||||||
|
let key = load_signing_key(&hex_str);
|
||||||
|
|
||||||
|
assert!(key.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_signing_key_invalid_hex() {
|
||||||
|
let result = load_signing_key("not_hex");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Invalid hex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_signing_key_wrong_length() {
|
||||||
|
let hex_str = "42".repeat(16);
|
||||||
|
|
||||||
|
let result = load_signing_key(&hex_str);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("32 bytes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_produces_valid_token() {
|
||||||
|
let key = test_signing_key();
|
||||||
|
let token = sign_license(&key, "pi_test_123", "hwid_abc", Some(1), None, None);
|
||||||
|
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parts.len(),
|
||||||
|
2,
|
||||||
|
"Token must have exactly one period separator"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(STANDARD.decode(parts[0]).is_ok());
|
||||||
|
assert!(STANDARD.decode(parts[1]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_roundtrip() {
|
||||||
|
let key = test_signing_key();
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let token = sign_license(&key, "pi_test_123", "hwid_abc", Some(2), Some(5), None);
|
||||||
|
|
||||||
|
let payload = verify_license(&token, &pub_key, "hwid_abc").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload.license_key, "pi_test_123");
|
||||||
|
assert_eq!(payload.hwid, "hwid_abc");
|
||||||
|
assert_eq!(payload.device_count, Some(2));
|
||||||
|
assert_eq!(payload.max_devices, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_wrong_hwid_fails() {
|
||||||
|
let key = test_signing_key();
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let token = sign_license(&key, "pi_test_123", "hwid_abc", Some(1), None, None);
|
||||||
|
|
||||||
|
let result = verify_license(&token, &pub_key, "hwid_wrong");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Hardware ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_wrong_key_fails() {
|
||||||
|
let key1 = test_signing_key();
|
||||||
|
let key2 = SigningKey::from_bytes(&[99u8; 32]);
|
||||||
|
let pub_key2 = key2.verifying_key().to_bytes();
|
||||||
|
let token = sign_license(&key1, "pi_test_123", "hwid_abc", Some(1), None, None);
|
||||||
|
|
||||||
|
let result = verify_license(&token, &pub_key2, "hwid_abc");
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_with_expires_at() {
|
||||||
|
let key = test_signing_key();
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let token = sign_license(
|
||||||
|
&key,
|
||||||
|
"pi_test_123",
|
||||||
|
"hwid_abc",
|
||||||
|
Some(1),
|
||||||
|
None,
|
||||||
|
Some("2025-12-31"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = verify_license(&token, &pub_key, "hwid_abc").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload.expires_at.as_deref(), Some("2025-12-31"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_license_unlimited_devices() {
|
||||||
|
let key = test_signing_key();
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let token = sign_license(&key, "pi_test_123", "hwid_abc", Some(3), None, None);
|
||||||
|
|
||||||
|
let payload = verify_license(&token, &pub_key, "hwid_abc").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload.device_count, Some(3));
|
||||||
|
assert!(payload.max_devices.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct License {
|
||||||
|
pub id: i64,
|
||||||
|
pub payment_id: String,
|
||||||
|
pub hwid: String,
|
||||||
|
pub device_count: i64,
|
||||||
|
pub max_devices: Option<i64>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
if let Some(path) = database_url
|
||||||
|
.strip_prefix("sqlite:")
|
||||||
|
.and_then(|s| s.split('?').next())
|
||||||
|
{
|
||||||
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new().connect(database_url).await?;
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_license(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
payment_id: &str,
|
||||||
|
hwid: &str,
|
||||||
|
max_devices: Option<i64>,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
) -> Result<License, sqlx::Error> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let result = sqlx::query_as::<_, License>(
|
||||||
|
"INSERT INTO licenses (payment_id, hwid, device_count, max_devices, expires_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||||||
|
RETURNING id, payment_id, hwid, device_count, max_devices, expires_at, created_at, updated_at"
|
||||||
|
)
|
||||||
|
.bind(payment_id)
|
||||||
|
.bind(hwid)
|
||||||
|
.bind(max_devices)
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_payment_id(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
payment_id: &str,
|
||||||
|
) -> Result<Option<License>, sqlx::Error> {
|
||||||
|
let result = sqlx::query_as::<_, License>(
|
||||||
|
"SELECT id, payment_id, hwid, device_count, max_devices, expires_at, created_at, updated_at
|
||||||
|
FROM licenses WHERE payment_id = ?",
|
||||||
|
)
|
||||||
|
.bind(payment_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn increment_device_count(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
payment_id: &str,
|
||||||
|
) -> Result<License, sqlx::Error> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query_as::<_, License>(
|
||||||
|
"UPDATE licenses SET device_count = device_count + 1, updated_at = ?
|
||||||
|
WHERE payment_id = ? RETURNING id, payment_id, hwid, device_count, max_devices, expires_at, created_at, updated_at"
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(payment_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn setup_db() -> SqlitePool {
|
||||||
|
create_pool("sqlite::memory:").await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_insert_new_license() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
let license = insert_license(&pool, "pi_test_123", "hwid_abc", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(license.payment_id, "pi_test_123");
|
||||||
|
assert_eq!(license.hwid, "hwid_abc");
|
||||||
|
assert_eq!(license.device_count, 1);
|
||||||
|
assert!(license.max_devices.is_none());
|
||||||
|
assert!(license.expires_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_insert_with_limits() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
let license = insert_license(
|
||||||
|
&pool,
|
||||||
|
"pi_test_456",
|
||||||
|
"hwid_def",
|
||||||
|
Some(3),
|
||||||
|
Some("2025-12-31"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(license.max_devices, Some(3));
|
||||||
|
assert_eq!(license.expires_at.as_deref(), Some("2025-12-31"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_insert_duplicate_payment_id_same_hwid() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
insert_license(&pool, "pi_test_123", "hwid_abc", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = insert_license(&pool, "pi_test_123", "hwid_abc", None, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_find_by_payment_id_found() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
insert_license(&pool, "pi_test_123", "hwid_abc", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = find_by_payment_id(&pool, "pi_test_123").await.unwrap();
|
||||||
|
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().hwid, "hwid_abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_find_by_payment_id_not_found() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
let found = find_by_payment_id(&pool, "pi_nonexistent").await.unwrap();
|
||||||
|
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn test_increment_device_count() {
|
||||||
|
let pool = setup_db().await;
|
||||||
|
|
||||||
|
insert_license(&pool, "pi_test_123", "hwid_abc", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let updated = increment_device_count(&pool, "pi_test_123").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated.device_count, 2);
|
||||||
|
|
||||||
|
let updated = increment_device_count(&pool, "pi_test_123").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated.device_count, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::db;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ActivateRequest {
|
||||||
|
pub license_key: String,
|
||||||
|
pub hwid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ActivateResponse {
|
||||||
|
pub license_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ActivationError {
|
||||||
|
MissingFields,
|
||||||
|
PaymentNotFound,
|
||||||
|
PaymentNotSucceeded,
|
||||||
|
DeviceLimitExceeded,
|
||||||
|
DatabaseError(String),
|
||||||
|
SigningError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ActivationError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
ActivationError::MissingFields => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"Missing required fields: license_key and hwid".to_string(),
|
||||||
|
),
|
||||||
|
ActivationError::PaymentNotFound => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Payment not found. Please check your payment ID and try again.".to_string(),
|
||||||
|
),
|
||||||
|
ActivationError::PaymentNotSucceeded => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Payment has not been completed. Please complete your purchase first.".to_string(),
|
||||||
|
),
|
||||||
|
ActivationError::DeviceLimitExceeded => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Device limit reached for this license.".to_string(),
|
||||||
|
),
|
||||||
|
ActivationError::DatabaseError(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
),
|
||||||
|
ActivationError::SigningError(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Signing error: {}", e),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(ErrorResponse { error: message })).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate(
|
||||||
|
State(state): State<crate::AppState>,
|
||||||
|
Json(req): Json<ActivateRequest>,
|
||||||
|
) -> Result<Json<ActivateResponse>, ActivationError> {
|
||||||
|
if req.license_key.is_empty() || req.hwid.is_empty() {
|
||||||
|
return Err(ActivationError::MissingFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_payment(&state.stripe_client, &req.license_key).await?;
|
||||||
|
|
||||||
|
let existing = db::find_by_payment_id(&state.db, &req.license_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let (device_count, max_devices, expires_at) = match existing {
|
||||||
|
Some(license) => {
|
||||||
|
if license.hwid == req.hwid {
|
||||||
|
(
|
||||||
|
license.device_count as u32,
|
||||||
|
license.max_devices.map(|v| v as u32),
|
||||||
|
license.expires_at.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if let Some(max) = license.max_devices {
|
||||||
|
if license.device_count >= max {
|
||||||
|
return Err(ActivationError::DeviceLimitExceeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = db::increment_device_count(&state.db, &req.license_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
(
|
||||||
|
updated.device_count as u32,
|
||||||
|
updated.max_devices.map(|v| v as u32),
|
||||||
|
updated.expires_at.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
db::insert_license(&state.db, &req.license_key, &req.hwid, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
(1, None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = crypto::sign_license(
|
||||||
|
&state.signing_key,
|
||||||
|
&req.license_key,
|
||||||
|
&req.hwid,
|
||||||
|
Some(device_count),
|
||||||
|
max_devices,
|
||||||
|
expires_at.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(ActivateResponse {
|
||||||
|
license_token: token,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_payment(client: &stripe::Client, payment_id: &str) -> Result<(), ActivationError> {
|
||||||
|
let pi_id: stripe::PaymentIntentId = payment_id
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ActivationError::PaymentNotFound)?;
|
||||||
|
|
||||||
|
let pi = stripe::PaymentIntent::retrieve(client, &pi_id, &[])
|
||||||
|
.await
|
||||||
|
.map_err(|_| ActivationError::PaymentNotFound)?;
|
||||||
|
|
||||||
|
match pi.status {
|
||||||
|
stripe::PaymentIntentStatus::Succeeded => Ok(()),
|
||||||
|
_ => Err(ActivationError::PaymentNotSucceeded),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::db::insert_license;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn setup() -> (SqlitePool, SigningKey) {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||||
|
(pool, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_new_license() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
let req = ActivateRequest {
|
||||||
|
license_key: "pi_test_12345".to_string(),
|
||||||
|
hwid: "hwid_abc123".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = activate_with_deps(&pool, &key, req).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let resp = result.unwrap();
|
||||||
|
|
||||||
|
assert!(!resp.license_token.is_empty());
|
||||||
|
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let payload = crypto::verify_license(&resp.license_token, &pub_key, "hwid_abc123").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(payload.license_key, "pi_test_12345");
|
||||||
|
assert_eq!(payload.device_count, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_same_device_idempotent() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
let req = ActivateRequest {
|
||||||
|
license_key: "pi_test_12345".to_string(),
|
||||||
|
hwid: "hwid_abc123".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp1 = activate_with_deps(&pool, &key, req.clone()).await.unwrap();
|
||||||
|
let resp2 = activate_with_deps(&pool, &key, req).await.unwrap();
|
||||||
|
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let p1 = crypto::verify_license(&resp1.license_token, &pub_key, "hwid_abc123").unwrap();
|
||||||
|
let p2 = crypto::verify_license(&resp2.license_token, &pub_key, "hwid_abc123").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(p1.device_count, Some(1));
|
||||||
|
assert_eq!(p2.device_count, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_different_device_increments() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
let req1 = ActivateRequest {
|
||||||
|
license_key: "pi_test_12345".to_string(),
|
||||||
|
hwid: "hwid_device1".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp1 = activate_with_deps(&pool, &key, req1).await.unwrap();
|
||||||
|
|
||||||
|
let req2 = ActivateRequest {
|
||||||
|
license_key: "pi_test_12345".to_string(),
|
||||||
|
hwid: "hwid_device2".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp2 = activate_with_deps(&pool, &key, req2).await.unwrap();
|
||||||
|
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let p1 = crypto::verify_license(&resp1.license_token, &pub_key, "hwid_device1").unwrap();
|
||||||
|
let p2 = crypto::verify_license(&resp2.license_token, &pub_key, "hwid_device2").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(p1.device_count, Some(1));
|
||||||
|
assert_eq!(p2.device_count, Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_device_limit_exceeded() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
db::insert_license(&pool, "pi_test_limited", "hwid_device1", Some(1), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = ActivateRequest {
|
||||||
|
license_key: "pi_test_limited".to_string(),
|
||||||
|
hwid: "hwid_device2".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = activate_with_deps(&pool, &key, req).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
ActivationError::DeviceLimitExceeded => {}
|
||||||
|
other => panic!("Expected DeviceLimitExceeded, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_missing_fields() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
let req = ActivateRequest {
|
||||||
|
license_key: String::new(),
|
||||||
|
hwid: "hwid_abc".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = activate_with_deps(&pool, &key, req).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
ActivationError::MissingFields => {}
|
||||||
|
other => panic!("Expected MissingFields, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_invalid_payment_id() {
|
||||||
|
let (pool, key) = setup().await;
|
||||||
|
|
||||||
|
insert_license(&pool, "pi_real_payment", "hwid_abc", None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = ActivateRequest {
|
||||||
|
license_key: "pi_fake_payment".to_string(),
|
||||||
|
hwid: "hwid_abc".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = activate_with_deps(&pool, &key, req).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate_with_deps(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
signing_key: &SigningKey,
|
||||||
|
req: ActivateRequest,
|
||||||
|
) -> Result<ActivateResponse, ActivationError> {
|
||||||
|
if req.license_key.is_empty() || req.hwid.is_empty() {
|
||||||
|
return Err(ActivationError::MissingFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = db::find_by_payment_id(pool, &req.license_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let (device_count, max_devices, expires_at) = match existing {
|
||||||
|
Some(license) => {
|
||||||
|
if license.hwid == req.hwid {
|
||||||
|
(
|
||||||
|
license.device_count as u32,
|
||||||
|
license.max_devices.map(|v| v as u32),
|
||||||
|
license.expires_at.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if let Some(max) = license.max_devices {
|
||||||
|
if license.device_count >= max {
|
||||||
|
return Err(ActivationError::DeviceLimitExceeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let updated = db::increment_device_count(pool, &req.license_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
(
|
||||||
|
updated.device_count as u32,
|
||||||
|
updated.max_devices.map(|v| v as u32),
|
||||||
|
updated.expires_at.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
db::insert_license(pool, &req.license_key, &req.hwid, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ActivationError::DatabaseError(e.to_string()))?;
|
||||||
|
(1, None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = crypto::sign_license(
|
||||||
|
signing_key,
|
||||||
|
&req.license_key,
|
||||||
|
&req.hwid,
|
||||||
|
Some(device_count),
|
||||||
|
max_devices,
|
||||||
|
expires_at.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ActivateResponse {
|
||||||
|
license_token: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
|
||||||
|
pub async fn checkout(
|
||||||
|
State(state): State<crate::AppState>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let price_id = &state.config.stripe_price_id;
|
||||||
|
if price_id.is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Stripe price not configured".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let success_url = format!(
|
||||||
|
"{}/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||||
|
state.config.app_url
|
||||||
|
);
|
||||||
|
let cancel_url = format!("{}/", state.config.app_url);
|
||||||
|
|
||||||
|
let params = stripe::CreateCheckoutSession {
|
||||||
|
mode: Some(stripe::CheckoutSessionMode::Payment),
|
||||||
|
line_items: Some(vec![stripe::CreateCheckoutSessionLineItems {
|
||||||
|
price: Some(price_id.to_string()),
|
||||||
|
quantity: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
success_url: Some(&success_url),
|
||||||
|
cancel_url: Some(&cancel_url),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = stripe::CheckoutSession::create(&state.stripe_client, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Stripe error: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = session.url.ok_or((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"No checkout URL returned".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(Redirect::to(&url).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
fn test_config() -> crate::config::Config {
|
||||||
|
crate::config::Config {
|
||||||
|
stripe_secret_key: "sk_test_fake".to_string(),
|
||||||
|
stripe_price_id: "price_test_123".to_string(),
|
||||||
|
signing_key_hex: "0".repeat(64),
|
||||||
|
database_url: "sqlite::memory:".to_string(),
|
||||||
|
release_dir: "/tmp/releases".to_string(),
|
||||||
|
static_dir: "./static".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
app_url: "http://localhost:3000".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_checkout_missing_price_id() {
|
||||||
|
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let state = crate::AppState {
|
||||||
|
config: crate::config::Config {
|
||||||
|
stripe_price_id: String::new(),
|
||||||
|
..test_config()
|
||||||
|
},
|
||||||
|
db: pool,
|
||||||
|
signing_key: key,
|
||||||
|
stripe_client: stripe::Client::new("sk_test_fake"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = checkout(State(state)).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let (status, msg) = result.unwrap_err();
|
||||||
|
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(msg.contains("not configured"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "docs.html")]
|
||||||
|
pub struct DocsTemplate;
|
||||||
|
|
||||||
|
pub async fn docs_page() -> DocsTemplate {
|
||||||
|
DocsTemplate
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((version, arch, filename)): Path<(String, String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let release_dir = PathBuf::from(&state.config.release_dir);
|
||||||
|
let file_path = release_dir.join(&version).join(&arch).join(&filename);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Release {} for {} not found: {}", version, arch, filename),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read(&file_path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let headers = [
|
||||||
|
("Content-Type", "application/octet-stream".to_string()),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
(headers, bytes).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to read release file: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::Config;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
async fn setup() -> (AppState, tempfile::TempDir) {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let release_dir = dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
stripe_secret_key: "sk_test_placeholder".to_string(),
|
||||||
|
stripe_price_id: String::new(),
|
||||||
|
signing_key_hex: "42".repeat(64),
|
||||||
|
database_url: "sqlite::memory:".to_string(),
|
||||||
|
release_dir,
|
||||||
|
static_dir: "./static".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
app_url: "http://localhost:3000".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config,
|
||||||
|
db: pool,
|
||||||
|
signing_key,
|
||||||
|
stripe_client: stripe::Client::new("sk_test_placeholder"),
|
||||||
|
};
|
||||||
|
|
||||||
|
(state, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_download_serves_file() {
|
||||||
|
let (state, dir) = setup().await;
|
||||||
|
let release_dir = PathBuf::from(&state.config.release_dir);
|
||||||
|
|
||||||
|
let version_dir = release_dir.join("0.1.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&version_dir).unwrap();
|
||||||
|
std::fs::write(version_dir.join("clean-copy-0.1.0.exe"), b"fake binary").unwrap();
|
||||||
|
|
||||||
|
let response = download(
|
||||||
|
State(state),
|
||||||
|
Path((
|
||||||
|
"0.1.0".to_string(),
|
||||||
|
"x86_64".to_string(),
|
||||||
|
"clean-copy-0.1.0.exe".to_string(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
drop(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_download_not_found() {
|
||||||
|
let (state, _dir) = setup().await;
|
||||||
|
|
||||||
|
let response = download(
|
||||||
|
State(state),
|
||||||
|
Path((
|
||||||
|
"9.9.9".to_string(),
|
||||||
|
"x86_64".to_string(),
|
||||||
|
"clean-copy-9.9.9.exe".to_string(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_download_serves_msi() {
|
||||||
|
let (state, dir) = setup().await;
|
||||||
|
let release_dir = PathBuf::from(&state.config.release_dir);
|
||||||
|
|
||||||
|
let version_dir = release_dir.join("0.1.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&version_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
version_dir.join("CleanCopy_0.1.0_x64_en-US.msi"),
|
||||||
|
b"fake msi",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = download(
|
||||||
|
State(state),
|
||||||
|
Path((
|
||||||
|
"0.1.0".to_string(),
|
||||||
|
"x86_64".to_string(),
|
||||||
|
"CleanCopy_0.1.0_x64_en-US.msi".to_string(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
drop(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "404.html")]
|
||||||
|
pub struct NotFoundTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "500.html")]
|
||||||
|
pub struct InternalErrorTemplate {
|
||||||
|
pub email: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn not_found() -> NotFoundTemplate {
|
||||||
|
NotFoundTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn internal_error_page() -> InternalErrorTemplate {
|
||||||
|
InternalErrorTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal_error(_panic: Box<dyn Any + Send>) -> Response {
|
||||||
|
InternalErrorTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
status: &'static str,
|
||||||
|
message: &'static str,
|
||||||
|
version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn healthz() -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
message: "CleanCopy server is alive and cleaning clipboards.",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "landing.html")]
|
||||||
|
pub struct LandingTemplate {
|
||||||
|
pub price: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn landing_page() -> LandingTemplate {
|
||||||
|
LandingTemplate {
|
||||||
|
price: crate::constants::PRICE,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod activate;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod docs;
|
||||||
|
pub mod download;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod healthz;
|
||||||
|
pub mod landing;
|
||||||
|
pub mod privacy;
|
||||||
|
pub mod refunds;
|
||||||
|
pub mod success;
|
||||||
|
pub mod terms;
|
||||||
|
pub mod updater;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "privacy.html")]
|
||||||
|
pub struct PrivacyTemplate {
|
||||||
|
pub email: &'static str,
|
||||||
|
pub date: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn privacy_page() -> PrivacyTemplate {
|
||||||
|
PrivacyTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
date: crate::constants::UPDATED_DATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "refunds.html")]
|
||||||
|
pub struct RefundsTemplate {
|
||||||
|
pub email: &'static str,
|
||||||
|
pub date: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refunds_page() -> RefundsTemplate {
|
||||||
|
RefundsTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
date: crate::constants::UPDATED_DATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "success.html")]
|
||||||
|
pub struct SuccessTemplate {
|
||||||
|
pub email: &'static str,
|
||||||
|
pub has_download: bool,
|
||||||
|
pub has_msi: bool,
|
||||||
|
pub download_version: String,
|
||||||
|
pub exe_url: String,
|
||||||
|
pub msi_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn success_page(State(state): State<AppState>) -> SuccessTemplate {
|
||||||
|
let latest = find_latest_release(&state.config.release_dir);
|
||||||
|
SuccessTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
has_download: latest.is_some(),
|
||||||
|
has_msi: latest
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| !r.msi_url.is_empty())
|
||||||
|
.unwrap_or(false),
|
||||||
|
download_version: latest
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.version.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
exe_url: latest
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.exe_url.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
msi_url: latest
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.msi_url.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VersionParts {
|
||||||
|
major: u32,
|
||||||
|
minor: u32,
|
||||||
|
patch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionParts {
|
||||||
|
fn parse(version: &str) -> Option<Self> {
|
||||||
|
let parts: Vec<&str> = version.trim_start_matches('v').split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
major: parts[0].parse().ok()?,
|
||||||
|
minor: parts[1].parse().ok()?,
|
||||||
|
patch: parts[2].parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_newer_than(&self, other: &Self) -> bool {
|
||||||
|
(self.major, self.minor, self.patch) > (other.major, other.minor, other.patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
format!("{}.{}.{}", self.major, self.minor, self.patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LatestRelease {
|
||||||
|
version: String,
|
||||||
|
exe_url: String,
|
||||||
|
msi_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_latest_release(release_dir: &str) -> Option<LatestRelease> {
|
||||||
|
let dir = PathBuf::from(release_dir);
|
||||||
|
if !dir.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(&dir).ok()?;
|
||||||
|
let mut latest: Option<VersionParts> = None;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if let Some(ver) = VersionParts::parse(&dir_name) {
|
||||||
|
if latest.as_ref().map_or(true, |l| ver.is_newer_than(l)) {
|
||||||
|
latest = Some(ver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = latest?;
|
||||||
|
let arch_dir = dir.join(latest.to_string()).join("x86_64");
|
||||||
|
if !arch_dir.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exe_file = find_file_with_ext(&arch_dir, "exe")?;
|
||||||
|
let msi_file = find_file_with_ext(&arch_dir, "msi");
|
||||||
|
|
||||||
|
let version = latest.to_string();
|
||||||
|
let exe_url = format!(
|
||||||
|
"/releases/download/{}/x86_64/{}",
|
||||||
|
version,
|
||||||
|
exe_file.file_name().to_string_lossy()
|
||||||
|
);
|
||||||
|
let msi_url = msi_file
|
||||||
|
.map(|f| {
|
||||||
|
format!(
|
||||||
|
"/releases/download/{}/x86_64/{}",
|
||||||
|
version,
|
||||||
|
f.file_name().to_string_lossy()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Some(LatestRelease {
|
||||||
|
version,
|
||||||
|
exe_url,
|
||||||
|
msi_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_file_with_ext(dir: &PathBuf, ext: &str) -> Option<std::fs::DirEntry> {
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.find(|e| {
|
||||||
|
e.file_type().map(|t| t.is_file()).unwrap_or(false)
|
||||||
|
&& e.path()
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.eq_ignore_ascii_case(ext))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_empty_dir() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(find_latest_release(&dir.path().to_string_lossy()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_no_arch_dir() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("0.1.0").join("aarch64")).unwrap();
|
||||||
|
assert!(find_latest_release(&dir.path().to_string_lossy()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_empty_arch_dir() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("0.1.0").join("x86_64")).unwrap();
|
||||||
|
assert!(find_latest_release(&dir.path().to_string_lossy()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_exe_only() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let arch_dir = dir.path().join("0.1.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&arch_dir).unwrap();
|
||||||
|
std::fs::write(arch_dir.join("clean-copy-0.1.0.exe"), b"fake").unwrap();
|
||||||
|
|
||||||
|
let result = find_latest_release(&dir.path().to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(result.version, "0.1.0");
|
||||||
|
assert!(result.exe_url.contains("clean-copy-0.1.0.exe"));
|
||||||
|
assert!(result.msi_url.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_exe_and_msi() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let arch_dir = dir.path().join("0.2.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&arch_dir).unwrap();
|
||||||
|
std::fs::write(arch_dir.join("clean-copy-0.2.0.exe"), b"fake exe").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
arch_dir.join("CleanCopy_0.2.0_x64_en-US.msi"),
|
||||||
|
b"fake msi",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = find_latest_release(&dir.path().to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(result.version, "0.2.0");
|
||||||
|
assert!(result.exe_url.contains("clean-copy-0.2.0.exe"));
|
||||||
|
assert!(result
|
||||||
|
.msi_url
|
||||||
|
.contains("CleanCopy_0.2.0_x64_en-US.msi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_picks_highest() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
|
||||||
|
for version in &["0.1.0", "0.3.0", "0.2.0"] {
|
||||||
|
let arch_dir = dir.path().join(version).join("x86_64");
|
||||||
|
std::fs::create_dir_all(&arch_dir).unwrap();
|
||||||
|
std::fs::write(arch_dir.join("app.exe"), b"fake").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = find_latest_release(&dir.path().to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(result.version, "0.3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_skips_invalid_dirs() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
|
||||||
|
let valid = dir.path().join("0.2.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&valid).unwrap();
|
||||||
|
std::fs::write(valid.join("app.exe"), b"fake").unwrap();
|
||||||
|
|
||||||
|
std::fs::create_dir_all(dir.path().join("not-a-version").join("x86_64")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("0.1").join("x86_64")).unwrap();
|
||||||
|
|
||||||
|
let result = find_latest_release(&dir.path().to_string_lossy()).unwrap();
|
||||||
|
assert_eq!(result.version, "0.2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_nonexistent_dir() {
|
||||||
|
assert!(find_latest_release("/nonexistent/path").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_latest_release_msi_only_no_exe() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let arch_dir = dir.path().join("0.1.0").join("x86_64");
|
||||||
|
std::fs::create_dir_all(&arch_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
arch_dir.join("CleanCopy_0.1.0_x64_en-US.msi"),
|
||||||
|
b"fake msi",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(find_latest_release(&dir.path().to_string_lossy()).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "terms.html")]
|
||||||
|
pub struct TermsTemplate {
|
||||||
|
pub email: &'static str,
|
||||||
|
pub date: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn terms_page() -> TermsTemplate {
|
||||||
|
TermsTemplate {
|
||||||
|
email: crate::constants::CONTACT_EMAIL,
|
||||||
|
date: crate::constants::UPDATED_DATE,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdateManifest {
|
||||||
|
pub version: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub pub_date: String,
|
||||||
|
pub platforms: UpdatePlatforms,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdatePlatforms {
|
||||||
|
#[serde(rename = "windows-x86_64")]
|
||||||
|
pub windows_x86_64: Option<PlatformEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PlatformEntry {
|
||||||
|
pub signature: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct VersionParts {
|
||||||
|
pub major: u32,
|
||||||
|
pub minor: u32,
|
||||||
|
pub patch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionParts {
|
||||||
|
pub fn parse(version: &str) -> Option<Self> {
|
||||||
|
let parts: Vec<&str> = version.trim_start_matches('v').split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
major: parts[0].parse().ok()?,
|
||||||
|
minor: parts[1].parse().ok()?,
|
||||||
|
patch: parts[2].parse().ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_newer_than(&self, other: &Self) -> bool {
|
||||||
|
(self.major, self.minor, self.patch) > (other.major, other.minor, other.patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_string(&self) -> String {
|
||||||
|
format!("{}.{}.{}", self.major, self.minor, self.patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn updater(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((_target, arch, current_version)): Path<(String, String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let current = match VersionParts::parse(¤t_version) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({ "error": "Invalid version format" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let release_dir = PathBuf::from(&state.config.release_dir);
|
||||||
|
|
||||||
|
if !release_dir.exists() {
|
||||||
|
return StatusCode::NO_CONTENT.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut latest: Option<VersionParts> = None;
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(&release_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return StatusCode::NO_CONTENT.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if let Some(ver) = VersionParts::parse(&dir_name) {
|
||||||
|
if ver.is_newer_than(¤t) {
|
||||||
|
if latest.as_ref().map_or(true, |l| ver.is_newer_than(l)) {
|
||||||
|
latest = Some(ver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = match latest {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return StatusCode::NO_CONTENT.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let platform_dir = release_dir.join(latest.to_string()).join(&arch);
|
||||||
|
if !platform_dir.exists() {
|
||||||
|
return StatusCode::NO_CONTENT.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the release file
|
||||||
|
let file_entry = std::fs::read_dir(&platform_dir).ok().and_then(|entries| {
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.find(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||||
|
});
|
||||||
|
|
||||||
|
let file_entry = match file_entry {
|
||||||
|
Some(e) => e,
|
||||||
|
None => return StatusCode::NO_CONTENT.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = file_entry.file_name().to_string_lossy().to_string();
|
||||||
|
let download_url = format!(
|
||||||
|
"{}/releases/download/{}/{}/{}",
|
||||||
|
state.config.app_url,
|
||||||
|
latest.to_string(),
|
||||||
|
arch,
|
||||||
|
file_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let manifest = UpdateManifest {
|
||||||
|
version: latest.to_string(),
|
||||||
|
notes: format!("Update to version {}", latest.to_string()),
|
||||||
|
pub_date: chrono::Utc::now().to_rfc3339(),
|
||||||
|
platforms: UpdatePlatforms {
|
||||||
|
windows_x86_64: Some(PlatformEntry {
|
||||||
|
signature: String::new(), // TODO: Generate or store signature
|
||||||
|
url: download_url,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(manifest)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_parse_valid() {
|
||||||
|
let v = VersionParts::parse("0.2.0").unwrap();
|
||||||
|
assert_eq!(v.major, 0);
|
||||||
|
assert_eq!(v.minor, 2);
|
||||||
|
assert_eq!(v.patch, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_parse_with_v_prefix() {
|
||||||
|
let v = VersionParts::parse("v1.0.0").unwrap();
|
||||||
|
assert_eq!(v.major, 1);
|
||||||
|
assert_eq!(v.minor, 0);
|
||||||
|
assert_eq!(v.patch, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_parse_invalid() {
|
||||||
|
assert!(VersionParts::parse("invalid").is_none());
|
||||||
|
assert!(VersionParts::parse("1.0").is_none());
|
||||||
|
assert!(VersionParts::parse("1.0.0.0").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_is_newer_than() {
|
||||||
|
let v1 = VersionParts::parse("0.1.0").unwrap();
|
||||||
|
let v2 = VersionParts::parse("0.2.0").unwrap();
|
||||||
|
let v3 = VersionParts::parse("1.0.0").unwrap();
|
||||||
|
|
||||||
|
assert!(v2.is_newer_than(&v1));
|
||||||
|
assert!(v3.is_newer_than(&v2));
|
||||||
|
assert!(!v1.is_newer_than(&v2));
|
||||||
|
assert!(!v1.is_newer_than(&v1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_to_string() {
|
||||||
|
let v = VersionParts::parse("1.2.3").unwrap();
|
||||||
|
assert_eq!(v.to_string(), "1.2.3");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod db;
|
||||||
|
pub mod handlers;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: config::Config,
|
||||||
|
pub db: SqlitePool,
|
||||||
|
pub signing_key: SigningKey,
|
||||||
|
pub stripe_client: stripe::Client,
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use tokio::signal;
|
||||||
|
use tower_http::catch_panic::CatchPanicLayer;
|
||||||
|
|
||||||
|
use clean_copy_server::config;
|
||||||
|
use clean_copy_server::crypto;
|
||||||
|
use clean_copy_server::db;
|
||||||
|
use clean_copy_server::handlers;
|
||||||
|
use clean_copy_server::AppState;
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("Failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
.expect("Failed to install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => tracing::info!("Received Ctrl+C, shutting down"),
|
||||||
|
_ = terminate => tracing::info!("Received SIGTERM, shutting down"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let config = config::Config::from_env();
|
||||||
|
|
||||||
|
let pool = db::create_pool(&config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to initialize database");
|
||||||
|
|
||||||
|
let signing_key = crypto::load_signing_key(&config.signing_key_hex)
|
||||||
|
.expect("Failed to load signing key from SIGNING_KEY_HEX");
|
||||||
|
|
||||||
|
let stripe_client = stripe::Client::new(&config.stripe_secret_key);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: config.clone(),
|
||||||
|
db: pool.clone(),
|
||||||
|
signing_key,
|
||||||
|
stripe_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(handlers::landing::landing_page))
|
||||||
|
.route("/docs", get(handlers::docs::docs_page))
|
||||||
|
.route("/terms", get(handlers::terms::terms_page))
|
||||||
|
.route("/refunds", get(handlers::refunds::refunds_page))
|
||||||
|
.route("/success", get(handlers::success::success_page))
|
||||||
|
.route("/privacy", get(handlers::privacy::privacy_page))
|
||||||
|
.route(
|
||||||
|
"/api/activate",
|
||||||
|
axum::routing::post(handlers::activate::activate),
|
||||||
|
)
|
||||||
|
.route("/api/checkout", get(handlers::checkout::checkout))
|
||||||
|
.route(
|
||||||
|
"/releases/download/{version}/{arch}/{filename}",
|
||||||
|
get(handlers::download::download),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/updates/{target}/{arch}/{version}",
|
||||||
|
get(handlers::updater::updater),
|
||||||
|
)
|
||||||
|
.route("/healthz", get(handlers::healthz::healthz))
|
||||||
|
.route("/404", get(handlers::errors::not_found))
|
||||||
|
.route("/500", get(handlers::errors::internal_error_page))
|
||||||
|
.nest_service(
|
||||||
|
"/static",
|
||||||
|
tower_http::services::ServeDir::new(&config.static_dir),
|
||||||
|
)
|
||||||
|
.layer(CatchPanicLayer::custom(handlers::errors::internal_error))
|
||||||
|
.fallback(handlers::errors::not_found)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{}", config.port);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind to address");
|
||||||
|
|
||||||
|
tracing::info!("Server listening on {}", addr);
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
|
||||||
|
tracing::info!("Database connection closed");
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Page Not Found — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.error-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-text-on);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">∅</div>
|
||||||
|
<h1>Page Not Found</h1>
|
||||||
|
|
||||||
|
<div class="error-message">
|
||||||
|
<p>
|
||||||
|
This page has been cleaned from existence. It was probably full of
|
||||||
|
tracking parameters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="error-hint">
|
||||||
|
The URL you visited doesn't exist, or may have been moved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Something Went Wrong — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.error-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-text-on);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions a:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">⚠</div>
|
||||||
|
<h1>Something Went Wrong</h1>
|
||||||
|
|
||||||
|
<div class="error-message">
|
||||||
|
<p>
|
||||||
|
Our clipboard cleaner hit an unexpected bump. This has been logged
|
||||||
|
and we're looking into it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="error-hint">
|
||||||
|
If this keeps happening, please
|
||||||
|
<a href="mailto:{{ email }}">contact support</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
|
<title>{% block title %}CleanCopy{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: "CascadiaCode";
|
||||||
|
src: url("/static/fonts/CaskaydiaCoveNerdFont-Regular.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "CascadiaCode";
|
||||||
|
src: url("/static/fonts/CaskaydiaCoveNerdFont-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg-base: #1c2128;
|
||||||
|
--color-bg-elevated: #22272e;
|
||||||
|
--color-border-subtle: #2d333b;
|
||||||
|
--color-border-muted: #444c56;
|
||||||
|
--color-text-primary: #adbac7;
|
||||||
|
--color-text-secondary: #768390;
|
||||||
|
--color-text-emphasis: #cdd9e5;
|
||||||
|
--color-accent: #b39cd0;
|
||||||
|
--color-accent-hover: rgba(179, 156, 208, 0.85);
|
||||||
|
--color-accent-text-on: #1c2128;
|
||||||
|
--color-success: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: "CascadiaCode", monospace;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav-links a {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav-links a:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<nav class="topnav">
|
||||||
|
<a href="/" class="topnav-brand">CleanCopy</a>
|
||||||
|
<div class="topnav-links">
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div><a href="/terms">Terms</a> · <a href="/refunds">Refunds</a> · <a href="/privacy">Privacy</a></div>
|
||||||
|
<p>© 2026 CleanCopy. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<!-- Simple Analytics - 100% privacy-first analytics -->
|
||||||
|
<script async src="https://scripts.simpleanalyticscdn.com/latest.js"></script>
|
||||||
|
<noscript><img src="https://queue.simpleanalyticscdn.com/noscript.gif" alt="" referrerpolicy="no-referrer-when-downgrade" /></noscript>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Documentation — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
code {
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Home</a>
|
||||||
|
<h1>CleanCopy Documentation</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>What is CleanCopy?</h2>
|
||||||
|
<p>
|
||||||
|
CleanCopy is a lightweight clipboard cleaning tool for Windows. When
|
||||||
|
triggered, it instantly sanitizes the text currently in your clipboard
|
||||||
|
— stripping trackers, normalizing typography, and removing invisible
|
||||||
|
characters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Installation</h2>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Complete your purchase on the
|
||||||
|
<a href="/">homepage</a>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Check your receipt email for the download link and your payment ID.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Download and run the installer. CleanCopy will start automatically.
|
||||||
|
</li>
|
||||||
|
<li>The CleanCopy icon will appear in your system tray.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>How to Use</h2>
|
||||||
|
<p>
|
||||||
|
Copy text to your clipboard as usual. When you're ready to clean it:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
Press <kbd>Ctrl</kbd> + <kbd>C</kbd> twice quickly (default
|
||||||
|
shortcut).
|
||||||
|
</li>
|
||||||
|
<li>CleanCopy processes the clipboard text in place.</li>
|
||||||
|
<li>Paste the cleaned text wherever you need it.</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
That's it — there's no window to interact with. CleanCopy works
|
||||||
|
silently in the background.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>What Gets Cleaned</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Tracking parameters</strong> — Removes
|
||||||
|
<code>utm_source</code>, <code>utm_medium</code>,
|
||||||
|
<code>gclid</code>, <code>fbclid</code>, and other marketing
|
||||||
|
trackers from URLs while preserving functional query parameters.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Smart quotes & dashes</strong> — Converts curly quotes
|
||||||
|
(<code>" "</code> <code>' '</code>) to straight quotes and
|
||||||
|
em/en-dashes to hyphens.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Line breaks</strong> — Joins single-newline wraps from chat
|
||||||
|
apps and emails into continuous lines, while preserving paragraph
|
||||||
|
breaks.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Invisible characters</strong> — Removes zero-width spaces,
|
||||||
|
null bytes, and other invisible Unicode characters.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<p>
|
||||||
|
Open the settings window from the system tray icon or by pressing
|
||||||
|
<kbd>Ctrl</kbd> + <kbd>C</kbd> while no text is selected. From there
|
||||||
|
you can:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Toggle individual cleaning rules on or off.</li>
|
||||||
|
<li>Add custom URL tracking parameters to strip.</li>
|
||||||
|
<li>Change the activation shortcut.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Shortcut doesn't work</strong> — Another app may be using
|
||||||
|
the same shortcut. Try changing it in Settings.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>CleanCopy won't start</strong> — Make sure it's not blocked
|
||||||
|
by your antivirus. Add it to your allowlist if needed.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>License not activating</strong> — Ensure you have a stable
|
||||||
|
internet connection and that your payment ID (starting with
|
||||||
|
<code>pi_</code>) is correct.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}CleanCopy — Clipboard Cleaning for Windows{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-logo {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero .tagline {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature h3 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing {
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing .price {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing .detail {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
margin: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-text-on);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta a:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="hero">
|
||||||
|
<img src="/static/logo.png" alt="CleanCopy" class="hero-logo" />
|
||||||
|
<h1>CleanCopy</h1>
|
||||||
|
<p class="tagline">
|
||||||
|
Lightweight clipboard cleaning for Windows. Silently sanitizes your
|
||||||
|
clipboard text on trigger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pricing">
|
||||||
|
<div class="price">{{ price }}</div>
|
||||||
|
<div class="detail">
|
||||||
|
One-time purchase · Lifetime license · Unlimited devices
|
||||||
|
</div>
|
||||||
|
<div class="cta">
|
||||||
|
<a href="/api/checkout">Buy CleanCopy →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature">
|
||||||
|
<h3>Tracking Parameter Stripper</h3>
|
||||||
|
<p>
|
||||||
|
Removes marketing trackers (utm_*, gclid, fbclid, etc.) from URLs
|
||||||
|
while keeping functional query arguments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>Smart Quotes & Dash Fixer</h3>
|
||||||
|
<p>
|
||||||
|
Converts curly quotes and em/en-dashes to standard straight quotes
|
||||||
|
and hyphens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>Line-Break Normalizer</h3>
|
||||||
|
<p>
|
||||||
|
Joins single-newline wraps from chat apps into continuous lines
|
||||||
|
while preserving paragraphs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>Invisible Character Cleanup</h3>
|
||||||
|
<p>
|
||||||
|
Removes zero-width spaces, null bytes, and other invisible Unicode
|
||||||
|
characters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Privacy Policy — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.updated {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Home</a>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="updated">Last updated: {{ date }}</p>
|
||||||
|
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>
|
||||||
|
CleanCopy is a clipboard cleaning tool that runs entirely on your
|
||||||
|
Windows PC. We value your privacy and have designed both our website and
|
||||||
|
software to collect only what is necessary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Website Analytics</h2>
|
||||||
|
<p>
|
||||||
|
This website uses
|
||||||
|
<a href="https://simpleanalytics.com" target="_blank">Simple Analytics</a>, a
|
||||||
|
privacy-friendly analytics service. Simple Analytics collects:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Page views and referrer information</li>
|
||||||
|
<li>Browser type and device type</li>
|
||||||
|
<li>Country-level geographic location (not precise location)</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Simple Analytics does <strong>not</strong> use cookies, does not track
|
||||||
|
individual users across sites, and does not collect personal
|
||||||
|
information. No consent banner is required because no personal data is
|
||||||
|
collected. See the
|
||||||
|
<a href="https://simpleanalytics.com/privacy" target="_blank">Simple Analytics privacy
|
||||||
|
policy</a>
|
||||||
|
for details.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>License Activation</h2>
|
||||||
|
<p>
|
||||||
|
When you activate CleanCopy, the app sends the following to our
|
||||||
|
activation server:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Payment ID</strong> — the <code>pi_...</code> identifier from
|
||||||
|
your Stripe receipt email
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Hardware ID (HWID)</strong> — a SHA-256 hash derived from your
|
||||||
|
Windows machine identifiers
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
This data is used solely to verify your purchase and issue a signed
|
||||||
|
license token. The HWID is stored in the license token to prevent
|
||||||
|
license sharing. We do not collect your name, email address, IP address,
|
||||||
|
or any other personal information during activation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>License Storage</h2>
|
||||||
|
<p>
|
||||||
|
Your signed license token is stored locally on your device at
|
||||||
|
<code><app_config_dir>/license.lic</code>. This file never leaves
|
||||||
|
your device. It is a digitally signed JSON payload containing your
|
||||||
|
payment ID, HWID, and license metadata.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Payments</h2>
|
||||||
|
<p>
|
||||||
|
All payment processing is handled by
|
||||||
|
<a href="https://stripe.com" target="_blank">Stripe</a>
|
||||||
|
as the merchant of record. CleanCopy never sees, stores, or has access
|
||||||
|
to your credit card number, bank details, or other payment credentials.
|
||||||
|
Stripe's own
|
||||||
|
<a href="https://stripe.com/privacy" target="_blank">privacy policy</a>
|
||||||
|
governs how your payment information is handled.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Data Sharing</h2>
|
||||||
|
<p>
|
||||||
|
We do not sell, trade, or share your data with third parties except as
|
||||||
|
described in this policy (Simple Analytics for website analytics, Stripe
|
||||||
|
for payment processing).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Cookies</h2>
|
||||||
|
<p>
|
||||||
|
This website does not use cookies. Simple Analytics operates without
|
||||||
|
cookies.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Changes to This Policy</h2>
|
||||||
|
<p>
|
||||||
|
We may update this Privacy Policy from time to time. Changes will be
|
||||||
|
reflected on this page with an updated date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions about this Privacy Policy?
|
||||||
|
<a href="mailto:{{ email }}">Contact us</a>.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Refund Policy — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.updated {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Home</a>
|
||||||
|
<h1>Refund Policy</h1>
|
||||||
|
<p class="updated">Last updated: {{ date }}</p>
|
||||||
|
|
||||||
|
<h2>30-Day Money-Back Guarantee</h2>
|
||||||
|
<p>
|
||||||
|
We want you to be satisfied with CleanCopy. If you're not happy with
|
||||||
|
your purchase, you can request a full refund within 30 days of your
|
||||||
|
purchase date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="highlight">
|
||||||
|
<strong>How to request a refund:</strong>
|
||||||
|
<p style="margin-top: 0.5rem; margin-bottom: 0">
|
||||||
|
Email
|
||||||
|
<a href="mailto:{{ email }}">{{ email }}</a> with
|
||||||
|
your payment ID (the <code>pi_...</code> value from your receipt
|
||||||
|
email) and a brief reason for the refund. We'll process it within 2–3
|
||||||
|
business days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What happens after a refund</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Your license will be deactivated on our servers.</li>
|
||||||
|
<li>
|
||||||
|
CleanCopy will stop working on your device after the next license
|
||||||
|
check.
|
||||||
|
</li>
|
||||||
|
<li>You must delete the software and all copies from your devices.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Exceptions</h2>
|
||||||
|
<p>Refunds may be denied in the following cases:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Requests made more than 30 days after purchase.</li>
|
||||||
|
<li>Refund requests for the same purchase made previously.</li>
|
||||||
|
<li>Requests from users who have violated the Terms of Service.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Processing</h2>
|
||||||
|
<p>
|
||||||
|
Refunds are processed through Stripe to the original payment method.
|
||||||
|
Depending on your bank, it may take 5–10 business days for the refund to
|
||||||
|
appear on your statement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions about refunds?
|
||||||
|
<a href="mailto:{{ email }}">Contact us</a>.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Payment Successful — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.success-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions h3 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions ol {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions code {
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note strong {
|
||||||
|
color: var(--color-text-emphasis);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download {
|
||||||
|
text-align: center;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download h3 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download .version {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download .version strong {
|
||||||
|
color: var(--color-text-emphasis);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-text-on);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 280px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn-secondary:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download .placeholder {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="success-container">
|
||||||
|
<div class="checkmark">✓</div>
|
||||||
|
<h1>Payment Successful</h1>
|
||||||
|
<p>Thank you for purchasing CleanCopy!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download">
|
||||||
|
<h3>Download CleanCopy</h3>
|
||||||
|
{% if has_download %}
|
||||||
|
<p class="version">Latest version: <strong>{{ download_version }}</strong></p>
|
||||||
|
<div class="download-options">
|
||||||
|
<a href="{{ exe_url }}" class="download-btn">
|
||||||
|
Download for Windows (.exe)
|
||||||
|
<span class="download-hint">Portable — no installation required</span>
|
||||||
|
</a>
|
||||||
|
{% if has_msi %}
|
||||||
|
<a href="{{ msi_url }}" class="download-btn download-btn-secondary">
|
||||||
|
Download Installer (.msi)
|
||||||
|
<span class="download-hint">Traditional Windows installer</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="placeholder">No releases available yet. Check back soon or contact support.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<h3>How to activate your license</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open CleanCopy on your Windows PC</li>
|
||||||
|
<li>Go to the <strong>License</strong> tab</li>
|
||||||
|
<li>
|
||||||
|
Find the payment ID in your receipt email — it starts with
|
||||||
|
<code>pi_</code>
|
||||||
|
</li>
|
||||||
|
<li>Paste it into the license key field</li>
|
||||||
|
<li>Click <strong>Activate</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Can't find your payment ID?</strong> Check your email inbox (and
|
||||||
|
spam folder) for the receipt from CleanCopy. The payment ID is in the
|
||||||
|
receipt subject line or body.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Need help? <a href="mailto:{{ email }}">Contact support</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Terms of Service — CleanCopy{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.updated {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a class="back" href="/">← Home</a>
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<p class="updated">Last updated: {{ date }}</p>
|
||||||
|
|
||||||
|
<h2>1. Acceptance of Terms</h2>
|
||||||
|
<p>
|
||||||
|
By purchasing, downloading, or using CleanCopy ("the Software"), you
|
||||||
|
agree to be bound by these Terms of Service. If you do not agree, do not
|
||||||
|
use the Software.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. License Grant</h2>
|
||||||
|
<p>
|
||||||
|
CleanCopy is licensed, not sold. Upon successful payment, you are
|
||||||
|
granted a non-exclusive, non-transferable, perpetual license to install
|
||||||
|
and use the Software on devices you own or control, for personal or
|
||||||
|
commercial purposes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Usage Restrictions</h2>
|
||||||
|
<ul>
|
||||||
|
<li>You may not redistribute, sell, or sublicense the Software.</li>
|
||||||
|
<li>
|
||||||
|
You may not reverse engineer, decompile, or disassemble the Software.
|
||||||
|
</li>
|
||||||
|
<li>You may not use the Software for any unlawful purpose.</li>
|
||||||
|
<li>
|
||||||
|
You may not remove or alter any proprietary notices or labels on the
|
||||||
|
Software.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Intellectual Property</h2>
|
||||||
|
<p>
|
||||||
|
The Software and all intellectual property rights therein are and remain
|
||||||
|
the exclusive property of CleanCopy. These Terms do not grant you any
|
||||||
|
rights to use CleanCopy's trademarks, logos, or other brand features.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Payment</h2>
|
||||||
|
<p>
|
||||||
|
CleanCopy is a one-time purchase. Payment is processed securely through
|
||||||
|
<a href="https://stripe.com" target="_blank">Stripe</a>, which
|
||||||
|
acts as the merchant of record for all transactions. This means:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Stripe is the seller of record and is responsible for processing your
|
||||||
|
payment, handling disputes, and managing chargebacks.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
CleanCopy never sees, stores, or has access to your credit card
|
||||||
|
number, bank details, or other payment credentials.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Stripe's own
|
||||||
|
<a href="https://stripe.com/legal/ssa" target="_blank">Stripe Services Agreement</a>
|
||||||
|
and
|
||||||
|
<a href="https://stripe.com/privacy" target="_blank">Privacy Policy</a>
|
||||||
|
govern how your payment information is collected and processed.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You are responsible for any taxes associated with your purchase.
|
||||||
|
Prices are in USD unless otherwise stated.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. Updates</h2>
|
||||||
|
<p>
|
||||||
|
We may release updates to the Software from time to time. Your license
|
||||||
|
includes access to updates for the version of CleanCopy you purchased.
|
||||||
|
We reserve the right to modify or discontinue features in future
|
||||||
|
versions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Disclaimer of Warranty</h2>
|
||||||
|
<p>
|
||||||
|
The Software is provided "as is" without warranty of any kind, express
|
||||||
|
or implied, including but not limited to the warranties of
|
||||||
|
merchantability, fitness for a particular purpose, and noninfringement.
|
||||||
|
We do not warrant that the Software will be uninterrupted, error-free,
|
||||||
|
or free of harmful components.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Limitation of Liability</h2>
|
||||||
|
<p>
|
||||||
|
To the maximum extent permitted by law, CleanCopy shall not be liable
|
||||||
|
for any indirect, incidental, special, consequential, or punitive
|
||||||
|
damages, or any loss of profits or revenue, whether incurred directly or
|
||||||
|
indirectly, or any loss of data, use, goodwill, or other intangible
|
||||||
|
losses resulting from your use of the Software.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Termination</h2>
|
||||||
|
<p>
|
||||||
|
Your license is effective until terminated. We may terminate your
|
||||||
|
license at any time if you fail to comply with these Terms. Upon
|
||||||
|
termination, you must cease all use of the Software and destroy all
|
||||||
|
copies.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>10. Governing Law</h2>
|
||||||
|
<p>
|
||||||
|
These Terms shall be governed by and construed in accordance with the
|
||||||
|
laws of the United States, without regard to conflict of law principles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>11. Changes to Terms</h2>
|
||||||
|
<p>
|
||||||
|
We reserve the right to update these Terms at any time. Continued use of
|
||||||
|
the Software after changes constitutes acceptance of the new Terms. We
|
||||||
|
will notify users of material changes through the Software or our
|
||||||
|
website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>12. Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions about these Terms?
|
||||||
|
<a href="mailto:{{ email }}">Contact us</a>.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::Router;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use wiremock::matchers::{any, method, path_regex};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
use clean_copy_server::crypto;
|
||||||
|
use clean_copy_server::handlers::activate;
|
||||||
|
use clean_copy_server::AppState;
|
||||||
|
|
||||||
|
fn payment_intent_json(id: &str, status: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "payment_intent",
|
||||||
|
"status": status,
|
||||||
|
"amount": 2900,
|
||||||
|
"amount_capturable": 0,
|
||||||
|
"amount_received": 0,
|
||||||
|
"currency": "usd",
|
||||||
|
"capture_method": "automatic",
|
||||||
|
"confirmation_method": "automatic",
|
||||||
|
"created": 1700000000,
|
||||||
|
"livemode": false,
|
||||||
|
"metadata": {},
|
||||||
|
"payment_method_types": ["card"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_app(mock_url: &str) -> (Router, SigningKey) {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||||
|
let stripe_client = stripe::Client::from_url(mock_url, "sk_test_fake");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: clean_copy_server::config::Config {
|
||||||
|
stripe_secret_key: "sk_test_fake".to_string(),
|
||||||
|
stripe_price_id: String::new(),
|
||||||
|
signing_key_hex: hex::encode(key.to_bytes()),
|
||||||
|
database_url: "sqlite::memory:".to_string(),
|
||||||
|
release_dir: "/tmp/releases".to_string(),
|
||||||
|
static_dir: "./static".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
app_url: "http://localhost:1420".to_string(),
|
||||||
|
},
|
||||||
|
db: pool,
|
||||||
|
signing_key: key.clone(),
|
||||||
|
stripe_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/activate", post(activate::activate))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
(app, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_new_license_with_mock_stripe() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
// Match ANY request to see what the Stripe client actually sends
|
||||||
|
Mock::given(any())
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(payment_intent_json("pi_test_123", "succeeded")),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (app, key) = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let req_body = serde_json::json!({
|
||||||
|
"license_key": "pi_test_123",
|
||||||
|
"hwid": "hwid_device_1"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token = body["license_token"].as_str().unwrap();
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let payload = crypto::verify_license(token, &pub_key, "hwid_device_1").unwrap();
|
||||||
|
assert_eq!(payload.license_key, "pi_test_123");
|
||||||
|
assert_eq!(payload.device_count, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_payment_not_succeeded() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(any())
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.set_body_json(payment_intent_json("pi_pending", "requires_payment_method")),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let req_body = serde_json::json!({
|
||||||
|
"license_key": "pi_pending",
|
||||||
|
"hwid": "hwid_device_1"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Payment has not been completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_payment_not_found() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex(r"/v1/payment_intents/pi_nonexistent"))
|
||||||
|
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": "No such PaymentIntent: 'pi_nonexistent'",
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let req_body = serde_json::json!({
|
||||||
|
"license_key": "pi_nonexistent",
|
||||||
|
"hwid": "hwid_device_1"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let body: serde_json::Value = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Payment not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_missing_fields() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
let (app, _key) = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let req_body = serde_json::json!({
|
||||||
|
"license_key": "",
|
||||||
|
"hwid": "hwid_device_1"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_activate_idempotent_same_device() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(any())
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(payment_intent_json("pi_test_123", "succeeded")),
|
||||||
|
)
|
||||||
|
.expect(2)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (app, key) = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let req_body = serde_json::json!({
|
||||||
|
"license_key": "pi_test_123",
|
||||||
|
"hwid": "hwid_device_1"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response1 = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response1.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let response2 = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/activate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_string(&req_body).unwrap()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response2.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body2: serde_json::Value = axum::body::to_bytes(response2.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.map(|b| serde_json::from_slice(&b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pub_key = key.verifying_key().to_bytes();
|
||||||
|
let payload = crypto::verify_license(
|
||||||
|
body2["license_token"].as_str().unwrap(),
|
||||||
|
&pub_key,
|
||||||
|
"hwid_device_1",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(payload.device_count, Some(1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use wiremock::matchers::{method, path_regex};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
use clean_copy_server::handlers::{activate, checkout};
|
||||||
|
use clean_copy_server::AppState;
|
||||||
|
|
||||||
|
async fn setup_app(mock_url: &str) -> Router {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||||
|
let stripe_client = stripe::Client::from_url(mock_url, "sk_test_fake");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
config: clean_copy_server::config::Config {
|
||||||
|
stripe_secret_key: "sk_test_fake".to_string(),
|
||||||
|
stripe_price_id: "price_test_123".to_string(),
|
||||||
|
signing_key_hex: hex::encode(key.to_bytes()),
|
||||||
|
database_url: "sqlite::memory:".to_string(),
|
||||||
|
release_dir: "/tmp/releases".to_string(),
|
||||||
|
static_dir: "./static".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
app_url: "http://localhost:3000".to_string(),
|
||||||
|
},
|
||||||
|
db: pool,
|
||||||
|
signing_key: key,
|
||||||
|
stripe_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/api/checkout", get(checkout::checkout))
|
||||||
|
.route("/api/activate", axum::routing::post(activate::activate))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkout_session_json(id: &str, url: Option<&str>) -> serde_json::Value {
|
||||||
|
let mut json = serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "checkout.session",
|
||||||
|
"mode": "payment",
|
||||||
|
"automatic_tax": { "enabled": false },
|
||||||
|
"created": 1700000000,
|
||||||
|
"expires_at": 1700003600,
|
||||||
|
"custom_fields": [],
|
||||||
|
"custom_text": {
|
||||||
|
"after_submit": null,
|
||||||
|
"shipping_address": null,
|
||||||
|
"submit": null,
|
||||||
|
"terms_of_service_acceptance": null
|
||||||
|
},
|
||||||
|
"livemode": false,
|
||||||
|
"payment_method_types": ["card"],
|
||||||
|
"payment_status": "unpaid",
|
||||||
|
"shipping_options": [],
|
||||||
|
});
|
||||||
|
if let Some(u) = url {
|
||||||
|
json["url"] = serde_json::json!(u);
|
||||||
|
}
|
||||||
|
json
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_checkout_redirects_to_stripe() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path_regex(r"/v1/checkout/sessions"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(checkout_session_json(
|
||||||
|
"cs_test_123",
|
||||||
|
Some("https://checkout.stripe.com/pay/cs_test_123"),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let app = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/checkout")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get("location")
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(location, "https://checkout.stripe.com/pay/cs_test_123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_checkout_stripe_error() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path_regex(r"/v1/checkout/sessions"))
|
||||||
|
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": "No such price: 'price_bad'",
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let app = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/checkout")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_checkout_no_url_in_response() {
|
||||||
|
let mock_server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path_regex(r"/v1/checkout/sessions"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(checkout_session_json("cs_test_no_url", None)),
|
||||||
|
)
|
||||||
|
.expect(1)
|
||||||
|
.mount(&mock_server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let app = setup_app(&mock_server.uri()).await;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/checkout")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user