feat(server): build axum web server for licensing, landing page, and updates

This commit is contained in:
Stevan Freeborn
2026-07-23 11:59:38 -05:00
parent 3b873f41a7
commit 792e6bd859
40 changed files with 3526 additions and 0 deletions
+24
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
tab_spaces = 2
+30
View File
@@ -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"] }
+19
View File
@@ -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"]
+32
View File
@@ -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
```
+13
View File
@@ -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);
+128
View File
@@ -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);
}
}
+5
View File
@@ -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";
+181
View File
@@ -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());
}
}
+183
View File
@@ -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);
}
}
+356
View File
@@ -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,
})
}
}
+94
View File
@@ -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"));
}
}
+10
View File
@@ -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
}
+159
View File
@@ -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);
}
}
+32
View File
@@ -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()
}
+17
View File
@@ -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"),
})
}
+14
View File
@@ -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,
}
}
+12
View File
@@ -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;
+16
View File
@@ -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,
}
}
+16
View File
@@ -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,
}
}
+249
View File
@@ -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());
}
}
+16
View File
@@ -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,
}
}
+192
View File
@@ -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(&current_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(&current) {
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");
}
}
+16
View File
@@ -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,
}
+105
View File
@@ -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.

After

Width:  |  Height:  |  Size: 13 KiB

+83
View File
@@ -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 %}
+84
View File
@@ -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 %}
+184
View File
@@ -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> &middot; <a href="/refunds">Refunds</a> &middot; <a href="/privacy">Privacy</a></div>
<p>&copy; 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>
+137
View File
@@ -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 &amp; 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 %}
+143
View File
@@ -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 &amp; 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 %}
+113
View File
@@ -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>&lt;app_config_dir&gt;/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 %}
+76
View File
@@ -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 23
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 510 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 %}
+186
View File
@@ -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 %}
+136
View File
@@ -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 %}
+292
View File
@@ -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));
}
+172
View File
@@ -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);
}