diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..d2d9246 --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/.rustfmt.toml b/server/.rustfmt.toml new file mode 100644 index 0000000..b196eaa --- /dev/null +++ b/server/.rustfmt.toml @@ -0,0 +1 @@ +tab_spaces = 2 diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..40209c1 --- /dev/null +++ b/server/Cargo.toml @@ -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"] } diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..2c8d795 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..ad15b21 --- /dev/null +++ b/server/README.md @@ -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 +``` diff --git a/server/migrations/001_init.sql b/server/migrations/001_init.sql new file mode 100644 index 0000000..41d6e6e --- /dev/null +++ b/server/migrations/001_init.sql @@ -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); diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..31c8f2c --- /dev/null +++ b/server/src/config.rs @@ -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); + } +} diff --git a/server/src/constants.rs b/server/src/constants.rs new file mode 100644 index 0000000..791af9d --- /dev/null +++ b/server/src/constants.rs @@ -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"; diff --git a/server/src/crypto.rs b/server/src/crypto.rs new file mode 100644 index 0000000..44cd75a --- /dev/null +++ b/server/src/crypto.rs @@ -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 { + 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, + max_devices: Option, + 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 { + 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()); + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..a13b090 --- /dev/null +++ b/server/src/db.rs @@ -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, + pub expires_at: Option, + pub created_at: String, + pub updated_at: String, +} + +pub async fn create_pool(database_url: &str) -> Result { + 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, + expires_at: Option<&str>, +) -> Result { + 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, 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 { + 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); + } +} diff --git a/server/src/handlers/activate.rs b/server/src/handlers/activate.rs new file mode 100644 index 0000000..fa26930 --- /dev/null +++ b/server/src/handlers/activate.rs @@ -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, + Json(req): Json, +) -> Result, 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 { + 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, + }) + } +} diff --git a/server/src/handlers/checkout.rs b/server/src/handlers/checkout.rs new file mode 100644 index 0000000..7922d90 --- /dev/null +++ b/server/src/handlers/checkout.rs @@ -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, +) -> Result { + 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")); + } +} diff --git a/server/src/handlers/docs.rs b/server/src/handlers/docs.rs new file mode 100644 index 0000000..1db3c88 --- /dev/null +++ b/server/src/handlers/docs.rs @@ -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 +} diff --git a/server/src/handlers/download.rs b/server/src/handlers/download.rs new file mode 100644 index 0000000..537e047 --- /dev/null +++ b/server/src/handlers/download.rs @@ -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, + 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); + } +} diff --git a/server/src/handlers/errors.rs b/server/src/handlers/errors.rs new file mode 100644 index 0000000..8dccd5b --- /dev/null +++ b/server/src/handlers/errors.rs @@ -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) -> Response { + InternalErrorTemplate { + email: crate::constants::CONTACT_EMAIL, + } + .into_response() +} diff --git a/server/src/handlers/healthz.rs b/server/src/handlers/healthz.rs new file mode 100644 index 0000000..a740ba8 --- /dev/null +++ b/server/src/handlers/healthz.rs @@ -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 { + Json(HealthResponse { + status: "ok", + message: "CleanCopy server is alive and cleaning clipboards.", + version: env!("CARGO_PKG_VERSION"), + }) +} diff --git a/server/src/handlers/landing.rs b/server/src/handlers/landing.rs new file mode 100644 index 0000000..78f3789 --- /dev/null +++ b/server/src/handlers/landing.rs @@ -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, + } +} diff --git a/server/src/handlers/mod.rs b/server/src/handlers/mod.rs new file mode 100644 index 0000000..455c414 --- /dev/null +++ b/server/src/handlers/mod.rs @@ -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; diff --git a/server/src/handlers/privacy.rs b/server/src/handlers/privacy.rs new file mode 100644 index 0000000..2d68554 --- /dev/null +++ b/server/src/handlers/privacy.rs @@ -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, + } +} diff --git a/server/src/handlers/refunds.rs b/server/src/handlers/refunds.rs new file mode 100644 index 0000000..cea5626 --- /dev/null +++ b/server/src/handlers/refunds.rs @@ -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, + } +} diff --git a/server/src/handlers/success.rs b/server/src/handlers/success.rs new file mode 100644 index 0000000..02d9f02 --- /dev/null +++ b/server/src/handlers/success.rs @@ -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) -> 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 { + 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 { + let dir = PathBuf::from(release_dir); + if !dir.exists() { + return None; + } + + let entries = std::fs::read_dir(&dir).ok()?; + let mut latest: Option = 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::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()); + } +} diff --git a/server/src/handlers/terms.rs b/server/src/handlers/terms.rs new file mode 100644 index 0000000..b2470c9 --- /dev/null +++ b/server/src/handlers/terms.rs @@ -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, + } +} diff --git a/server/src/handlers/updater.rs b/server/src/handlers/updater.rs new file mode 100644 index 0000000..d089cca --- /dev/null +++ b/server/src/handlers/updater.rs @@ -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, +} + +#[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 { + 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, + 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 = 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"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..5b3e883 --- /dev/null +++ b/server/src/lib.rs @@ -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, +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..0809030 --- /dev/null +++ b/server/src/main.rs @@ -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"); +} diff --git a/server/static/favicon.png b/server/static/favicon.png new file mode 100644 index 0000000..d139d7c Binary files /dev/null and b/server/static/favicon.png differ diff --git a/server/static/fonts/CaskaydiaCoveNerdFont-Bold.ttf b/server/static/fonts/CaskaydiaCoveNerdFont-Bold.ttf new file mode 100644 index 0000000..d7ec691 Binary files /dev/null and b/server/static/fonts/CaskaydiaCoveNerdFont-Bold.ttf differ diff --git a/server/static/fonts/CaskaydiaCoveNerdFont-Regular.ttf b/server/static/fonts/CaskaydiaCoveNerdFont-Regular.ttf new file mode 100644 index 0000000..b1d98df Binary files /dev/null and b/server/static/fonts/CaskaydiaCoveNerdFont-Regular.ttf differ diff --git a/server/static/logo.png b/server/static/logo.png new file mode 100644 index 0000000..fb0768a Binary files /dev/null and b/server/static/logo.png differ diff --git a/server/templates/404.html b/server/templates/404.html new file mode 100644 index 0000000..1d44be9 --- /dev/null +++ b/server/templates/404.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Page Not Found

+ +
+

+ This page has been cleaned from existence. It was probably full of + tracking parameters. +

+
+ +

+ The URL you visited doesn't exist, or may have been moved. +

+ + +
+{% endblock %} diff --git a/server/templates/500.html b/server/templates/500.html new file mode 100644 index 0000000..9d37b89 --- /dev/null +++ b/server/templates/500.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% block title %}Something Went Wrong — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Something Went Wrong

+ +
+

+ Our clipboard cleaner hit an unexpected bump. This has been logged + and we're looking into it. +

+
+ +

+ If this keeps happening, please + contact support. +

+ + +
+{% endblock %} diff --git a/server/templates/base.html b/server/templates/base.html new file mode 100644 index 0000000..e7f8e89 --- /dev/null +++ b/server/templates/base.html @@ -0,0 +1,184 @@ + + + + + + + + {% block title %}CleanCopy{% endblock %} + + {% block head %}{% endblock %} + + + +
+ +
+ {% block content %}{% endblock %} +
+ +
+ + + + + + diff --git a/server/templates/docs.html b/server/templates/docs.html new file mode 100644 index 0000000..95c7c83 --- /dev/null +++ b/server/templates/docs.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} + +{% block title %}Documentation — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +← Home +

CleanCopy Documentation

+ +
+

What is CleanCopy?

+

+ 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. +

+
+ +
+

Installation

+
    +
  1. + Complete your purchase on the + homepage. +
  2. +
  3. + Check your receipt email for the download link and your payment ID. +
  4. +
  5. + Download and run the installer. CleanCopy will start automatically. +
  6. +
  7. The CleanCopy icon will appear in your system tray.
  8. +
+
+ +
+

How to Use

+

+ Copy text to your clipboard as usual. When you're ready to clean it: +

+
    +
  1. + Press Ctrl + C twice quickly (default + shortcut). +
  2. +
  3. CleanCopy processes the clipboard text in place.
  4. +
  5. Paste the cleaned text wherever you need it.
  6. +
+

+ That's it — there's no window to interact with. CleanCopy works + silently in the background. +

+
+ +
+

What Gets Cleaned

+
    +
  • + Tracking parameters — Removes + utm_source, utm_medium, + gclid, fbclid, and other marketing + trackers from URLs while preserving functional query parameters. +
  • +
  • + Smart quotes & dashes — Converts curly quotes + (" " ' ') to straight quotes and + em/en-dashes to hyphens. +
  • +
  • + Line breaks — Joins single-newline wraps from chat + apps and emails into continuous lines, while preserving paragraph + breaks. +
  • +
  • + Invisible characters — Removes zero-width spaces, + null bytes, and other invisible Unicode characters. +
  • +
+
+ +
+

Configuration

+

+ Open the settings window from the system tray icon or by pressing + Ctrl + C while no text is selected. From there + you can: +

+
    +
  • Toggle individual cleaning rules on or off.
  • +
  • Add custom URL tracking parameters to strip.
  • +
  • Change the activation shortcut.
  • +
+
+ +
+

Troubleshooting

+
    +
  • + Shortcut doesn't work — Another app may be using + the same shortcut. Try changing it in Settings. +
  • +
  • + CleanCopy won't start — Make sure it's not blocked + by your antivirus. Add it to your allowlist if needed. +
  • +
  • + License not activating — Ensure you have a stable + internet connection and that your payment ID (starting with + pi_) is correct. +
  • +
+
+{% endblock %} diff --git a/server/templates/landing.html b/server/templates/landing.html new file mode 100644 index 0000000..a554f60 --- /dev/null +++ b/server/templates/landing.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}CleanCopy — Clipboard Cleaning for Windows{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ +

CleanCopy

+

+ Lightweight clipboard cleaning for Windows. Silently sanitizes your + clipboard text on trigger. +

+
+ +
+
{{ price }}
+
+ One-time purchase · Lifetime license · Unlimited devices +
+ +
+ +
+
+

Tracking Parameter Stripper

+

+ Removes marketing trackers (utm_*, gclid, fbclid, etc.) from URLs + while keeping functional query arguments. +

+
+
+

Smart Quotes & Dash Fixer

+

+ Converts curly quotes and em/en-dashes to standard straight quotes + and hyphens. +

+
+
+

Line-Break Normalizer

+

+ Joins single-newline wraps from chat apps into continuous lines + while preserving paragraphs. +

+
+
+

Invisible Character Cleanup

+

+ Removes zero-width spaces, null bytes, and other invisible Unicode + characters. +

+
+
+ +{% endblock %} diff --git a/server/templates/privacy.html b/server/templates/privacy.html new file mode 100644 index 0000000..91e65fa --- /dev/null +++ b/server/templates/privacy.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + +{% block title %}Privacy Policy — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +← Home +

Privacy Policy

+

Last updated: {{ date }}

+ +

Overview

+

+ 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. +

+ +

Website Analytics

+

+ This website uses + Simple Analytics, a + privacy-friendly analytics service. Simple Analytics collects: +

+
    +
  • Page views and referrer information
  • +
  • Browser type and device type
  • +
  • Country-level geographic location (not precise location)
  • +
+

+ Simple Analytics does not 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 + Simple Analytics privacy + policy + for details. +

+ +

License Activation

+

+ When you activate CleanCopy, the app sends the following to our + activation server: +

+
    +
  • + Payment ID — the pi_... identifier from + your Stripe receipt email +
  • +
  • + Hardware ID (HWID) — a SHA-256 hash derived from your + Windows machine identifiers +
  • +
+

+ 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. +

+ +

License Storage

+

+ Your signed license token is stored locally on your device at + <app_config_dir>/license.lic. This file never leaves + your device. It is a digitally signed JSON payload containing your + payment ID, HWID, and license metadata. +

+ +

Payments

+

+ All payment processing is handled by + Stripe + 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 + privacy policy + governs how your payment information is handled. +

+ +

Data Sharing

+

+ 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). +

+ +

Cookies

+

+ This website does not use cookies. Simple Analytics operates without + cookies. +

+ +

Changes to This Policy

+

+ We may update this Privacy Policy from time to time. Changes will be + reflected on this page with an updated date. +

+ +

Contact

+

+ Questions about this Privacy Policy? + Contact us. +

+{% endblock %} diff --git a/server/templates/refunds.html b/server/templates/refunds.html new file mode 100644 index 0000000..3f3c894 --- /dev/null +++ b/server/templates/refunds.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Refund Policy — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +← Home +

Refund Policy

+

Last updated: {{ date }}

+ +

30-Day Money-Back Guarantee

+

+ 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. +

+ +
+ How to request a refund: +

+ Email + {{ email }} with + your payment ID (the pi_... value from your receipt + email) and a brief reason for the refund. We'll process it within 2–3 + business days. +

+
+ +

What happens after a refund

+
    +
  • Your license will be deactivated on our servers.
  • +
  • + CleanCopy will stop working on your device after the next license + check. +
  • +
  • You must delete the software and all copies from your devices.
  • +
+ +

Exceptions

+

Refunds may be denied in the following cases:

+
    +
  • Requests made more than 30 days after purchase.
  • +
  • Refund requests for the same purchase made previously.
  • +
  • Requests from users who have violated the Terms of Service.
  • +
+ +

Processing

+

+ 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. +

+ +

Contact

+

+ Questions about refunds? + Contact us. +

+{% endblock %} diff --git a/server/templates/success.html b/server/templates/success.html new file mode 100644 index 0000000..a891c00 --- /dev/null +++ b/server/templates/success.html @@ -0,0 +1,186 @@ +{% extends "base.html" %} + +{% block title %}Payment Successful — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Payment Successful

+

Thank you for purchasing CleanCopy!

+
+ +
+

Download CleanCopy

+ {% if has_download %} +

Latest version: {{ download_version }}

+ + {% else %} +

No releases available yet. Check back soon or contact support.

+ {% endif %} +
+ +
+

How to activate your license

+
    +
  1. Open CleanCopy on your Windows PC
  2. +
  3. Go to the License tab
  4. +
  5. + Find the payment ID in your receipt email — it starts with + pi_ +
  6. +
  7. Paste it into the license key field
  8. +
  9. Click Activate
  10. +
+
+ +
+ Can't find your payment ID? Check your email inbox (and + spam folder) for the receipt from CleanCopy. The payment ID is in the + receipt subject line or body. +
+ +

Need help? Contact support

+{% endblock %} diff --git a/server/templates/terms.html b/server/templates/terms.html new file mode 100644 index 0000000..e9cc974 --- /dev/null +++ b/server/templates/terms.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} + +{% block title %}Terms of Service — CleanCopy{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +← Home +

Terms of Service

+

Last updated: {{ date }}

+ +

1. Acceptance of Terms

+

+ 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. +

+ +

2. License Grant

+

+ 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. +

+ +

3. Usage Restrictions

+
    +
  • You may not redistribute, sell, or sublicense the Software.
  • +
  • + You may not reverse engineer, decompile, or disassemble the Software. +
  • +
  • You may not use the Software for any unlawful purpose.
  • +
  • + You may not remove or alter any proprietary notices or labels on the + Software. +
  • +
+ +

4. Intellectual Property

+

+ 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. +

+ +

5. Payment

+

+ CleanCopy is a one-time purchase. Payment is processed securely through + Stripe, which + acts as the merchant of record for all transactions. This means: +

+
    +
  • + Stripe is the seller of record and is responsible for processing your + payment, handling disputes, and managing chargebacks. +
  • +
  • + CleanCopy never sees, stores, or has access to your credit card + number, bank details, or other payment credentials. +
  • +
  • + Stripe's own + Stripe Services Agreement + and + Privacy Policy + govern how your payment information is collected and processed. +
  • +
  • + You are responsible for any taxes associated with your purchase. + Prices are in USD unless otherwise stated. +
  • +
+ +

6. Updates

+

+ 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. +

+ +

7. Disclaimer of Warranty

+

+ 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. +

+ +

8. Limitation of Liability

+

+ 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. +

+ +

9. Termination

+

+ 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. +

+ +

10. Governing Law

+

+ These Terms shall be governed by and construed in accordance with the + laws of the United States, without regard to conflict of law principles. +

+ +

11. Changes to Terms

+

+ 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. +

+ +

12. Contact

+

+ Questions about these Terms? + Contact us. +

+{% endblock %} diff --git a/server/tests/activate_integration.rs b/server/tests/activate_integration.rs new file mode 100644 index 0000000..0bef720 --- /dev/null +++ b/server/tests/activate_integration.rs @@ -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)); +} diff --git a/server/tests/checkout_integration.rs b/server/tests/checkout_integration.rs new file mode 100644 index 0000000..9842c8c --- /dev/null +++ b/server/tests/checkout_integration.rs @@ -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); +}