From d1686955cb145c360aecb9fb5408b75ae398011b Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 11:02:18 +0100 Subject: [PATCH 01/53] feat: add agent-pdf Rust microservice --- agent-pdf/.do/app.yaml | 40 ++++++++++++ agent-pdf/Cargo.toml | 24 +++++++ agent-pdf/README.md | 37 +++++++++++ agent-pdf/src/config.rs | 38 +++++++++++ agent-pdf/src/error.rs | 38 +++++++++++ agent-pdf/src/main.rs | 43 ++++++++++++ agent-pdf/src/rate_limit.rs | 47 +++++++++++++ agent-pdf/src/routes.rs | 127 ++++++++++++++++++++++++++++++++++++ agent-pdf/src/storage.rs | 71 ++++++++++++++++++++ 9 files changed, 465 insertions(+) create mode 100644 agent-pdf/.do/app.yaml create mode 100644 agent-pdf/Cargo.toml create mode 100644 agent-pdf/README.md create mode 100644 agent-pdf/src/config.rs create mode 100644 agent-pdf/src/error.rs create mode 100644 agent-pdf/src/main.rs create mode 100644 agent-pdf/src/rate_limit.rs create mode 100644 agent-pdf/src/routes.rs create mode 100644 agent-pdf/src/storage.rs diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml new file mode 100644 index 0000000..199b9cb --- /dev/null +++ b/agent-pdf/.do/app.yaml @@ -0,0 +1,40 @@ +name: agent-pdf +region: ams +services: + - name: api + github: + repo: SimplePDF/simplepdf-embed + branch: main + deploy_on_push: true + source_dir: agent-pdf + buildpack: rust + instance_count: 1 + instance_size_slug: basic-xs + http_port: 8080 + health_check: + http_path: /health + envs: + - key: SPACES_KEY + scope: RUN_TIME + type: SECRET + - key: SPACES_SECRET + scope: RUN_TIME + type: SECRET + - key: SPACES_BUCKET + scope: RUN_TIME + value: agent-pdf + - key: SPACES_ENDPOINT + scope: RUN_TIME + value: https://ams3.digitaloceanspaces.com + - key: SPACES_REGION + scope: RUN_TIME + value: ams3 + - key: SPACES_PUBLIC_URL + scope: RUN_TIME + value: https://agent-pdf.ams3.cdn.digitaloceanspaces.com + - key: SIMPLEPDF_URL + scope: RUN_TIME + value: https://simplepdf.com + - key: RATE_LIMIT_PER_MIN + scope: RUN_TIME + value: "30" diff --git a/agent-pdf/Cargo.toml b/agent-pdf/Cargo.toml new file mode 100644 index 0000000..1e90c74 --- /dev/null +++ b/agent-pdf/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "agent-pdf" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +aws-sdk-s3 = "1" +aws-config = { version = "1", features = ["behavior-version-latest"] } +uuid = { version = "1", features = ["v4"] } +reqwest = { version = "0.12", features = ["stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "limit"] } +tracing = "0.1" +tracing-subscriber = "0.3" +dashmap = "6" + +[profile.release] +opt-level = "z" +lto = true +strip = true diff --git a/agent-pdf/README.md b/agent-pdf/README.md new file mode 100644 index 0000000..3762632 --- /dev/null +++ b/agent-pdf/README.md @@ -0,0 +1,37 @@ +# agent-pdf + +Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), stores it in DO Spaces, returns ready-to-embed URLs. + +## Endpoints + +### `POST /agents` + +Full PDF editor. Accepts JSON or multipart. + +```bash +# Via URL +curl -X POST https://your-app.ondigitalocean.app/agents \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/form.pdf"}' + +# Via file upload +curl -X POST https://your-app.ondigitalocean.app/agents \ + -F file=@document.pdf +``` + +## Deploy + +1. Create a DO Spaces bucket named `agent-pdf` with a lifecycle rule (1hr expiry) +2. Set the bucket ACL to public-read +3. Push to GitHub and deploy via App Platform (uses `.do/app.yaml`) +4. Set `SPACES_KEY` and `SPACES_SECRET` in the App Platform console + +## Architecture + +``` +Agent → POST /agents → Rust (upload to Spaces) → JSON response + ↓ +User clicks URL → simplepdf.com/editor?open=SPACES_URL → client-side editing +``` + +No database. No auth. No sessions. Bucket lifecycle handles cleanup. diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs new file mode 100644 index 0000000..ab2f7c3 --- /dev/null +++ b/agent-pdf/src/config.rs @@ -0,0 +1,38 @@ +pub struct Config { + /// DO Spaces bucket name + pub bucket: String, + /// DO Spaces endpoint (e.g. https://ams3.digitaloceanspaces.com) + pub spaces_endpoint: String, + /// DO Spaces region (e.g. ams3) + pub spaces_region: String, + /// CDN or direct URL prefix for public access + /// e.g. https://agent-pdf.ams3.cdn.digitaloceanspaces.com + pub public_url_prefix: String, + /// SimplePDF base URL + pub simplepdf_url: String, + /// Requests per IP per minute + pub rate_limit_per_minute: u32, +} + +impl Config { + pub fn from_env() -> Self { + Self { + bucket: env("SPACES_BUCKET"), + spaces_endpoint: env("SPACES_ENDPOINT"), + spaces_region: env_or("SPACES_REGION", "ams3"), + public_url_prefix: env("SPACES_PUBLIC_URL"), + simplepdf_url: env_or("SIMPLEPDF_URL", "https://simplepdf.com"), + rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") + .parse() + .expect("RATE_LIMIT_PER_MIN must be a number"), + } + } +} + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} must be set")) +} + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} diff --git a/agent-pdf/src/error.rs b/agent-pdf/src/error.rs new file mode 100644 index 0000000..db619b6 --- /dev/null +++ b/agent-pdf/src/error.rs @@ -0,0 +1,38 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +#[derive(Debug)] +pub enum AppError { + /// PDF too large or invalid + BadRequest(String), + /// Rate limit exceeded + RateLimited, + /// Failed to fetch URL + FetchFailed(String), + /// Storage error + StorageFailed(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded. Try again shortly.".into(), + ), + AppError::FetchFailed(msg) => (StatusCode::BAD_GATEWAY, msg.clone()), + AppError::StorageFailed(msg) => { + tracing::error!("storage error: {msg}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Upload failed. Try again.".into(), + ) + } + }; + + let body = axum::Json(json!({ "error": message })); + (status, body).into_response() + } +} diff --git a/agent-pdf/src/main.rs b/agent-pdf/src/main.rs new file mode 100644 index 0000000..3356f90 --- /dev/null +++ b/agent-pdf/src/main.rs @@ -0,0 +1,43 @@ +mod config; +mod error; +mod rate_limit; +mod routes; +mod storage; + +use axum::Router; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tower_http::limit::RequestBodyLimitLayer; + +pub struct AppState { + pub storage: storage::Storage, + pub rate_limiter: rate_limit::RateLimiter, + pub config: config::Config, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::init(); + + let config = config::Config::from_env(); + let storage = storage::Storage::new(&config).await; + let rate_limiter = rate_limit::RateLimiter::new(config.rate_limit_per_minute); + + let state = Arc::new(AppState { + storage, + rate_limiter, + config, + }); + + let app = Router::new() + .merge(routes::router()) + .layer(CorsLayer::permissive()) + .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) // 50MB max + .with_state(state); + + let addr = "0.0.0.0:8080"; + tracing::info!("listening on {addr}"); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/agent-pdf/src/rate_limit.rs b/agent-pdf/src/rate_limit.rs new file mode 100644 index 0000000..c7292c6 --- /dev/null +++ b/agent-pdf/src/rate_limit.rs @@ -0,0 +1,47 @@ +use dashmap::DashMap; +use std::net::IpAddr; +use std::time::Instant; + +pub struct RateLimiter { + max_per_minute: u32, + buckets: DashMap>, +} + +impl RateLimiter { + pub fn new(max_per_minute: u32) -> Self { + Self { + max_per_minute, + buckets: DashMap::new(), + } + } + + /// Returns true if the request should be allowed. + pub fn check(&self, ip: IpAddr) -> bool { + let now = Instant::now(); + let window = std::time::Duration::from_secs(60); + + let mut entry = self.buckets.entry(ip).or_default(); + let timestamps = entry.value_mut(); + + // Prune old entries + timestamps.retain(|t| now.duration_since(*t) < window); + + if timestamps.len() >= self.max_per_minute as usize { + return false; + } + + timestamps.push(now); + true + } + + /// Periodic cleanup of stale IPs. Call from a background task. + pub fn cleanup(&self) { + let now = Instant::now(); + let window = std::time::Duration::from_secs(60); + + self.buckets.retain(|_, timestamps| { + timestamps.retain(|t| now.duration_since(*t) < window); + !timestamps.is_empty() + }); + } +} diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs new file mode 100644 index 0000000..464d8d5 --- /dev/null +++ b/agent-pdf/src/routes.rs @@ -0,0 +1,127 @@ +use axum::extract::{ConnectInfo, Multipart, State}; +use axum::response::Json; +use axum::routing::post; +use axum::Router; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::sync::Arc; + +use crate::error::AppError; +use crate::AppState; + +pub fn router() -> Router> { + Router::new() + .route("/agents", post(handle_agents)) + .route("/health", axum::routing::get(|| async { "ok" })) +} + +#[derive(Deserialize)] +struct UrlInput { + url: String, +} + +#[derive(Serialize)] +struct AgentResponse { + id: String, + url: String, + iframe: String, + react: String, +} + +/// POST /agents +/// Accepts either: +/// - JSON body: { "url": "https://..." } +/// - Multipart form: file field with PDF binary +/// +/// Returns embed codes for the editor. +async fn handle_agents( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + request: axum::extract::Request, +) -> Result, AppError> { + if !state.rate_limiter.check(addr.ip()) { + return Err(AppError::RateLimited); + } + + let content_type = request + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let pdf_bytes = if content_type.starts_with("multipart/form-data") { + extract_multipart(request).await? + } else { + let body = axum::body::to_bytes(request.into_body(), 1024 * 64) + .await + .map_err(|_| AppError::BadRequest("Invalid request body".into()))?; + + let input: UrlInput = serde_json::from_slice(&body) + .map_err(|_| AppError::BadRequest("Expected JSON with 'url' field or multipart upload".into()))?; + + fetch_pdf(&input.url).await? + }; + + let result = state.storage.upload(pdf_bytes).await?; + let base = &state.config.simplepdf_url; + + let url = format!("{base}/editor?open={}", result.public_url); + let iframe = format!( + r#""# + ); + let react = format!(r#""#, result.public_url); + + Ok(Json(AgentResponse { + id: result.id, + url, + iframe, + react, + })) +} + +async fn extract_multipart(request: axum::extract::Request) -> Result, AppError> { + let mut multipart = Multipart::from_request(request, &()) + .await + .map_err(|_| AppError::BadRequest("Invalid multipart data".into()))?; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| AppError::BadRequest("Failed to read multipart field".into()))? + { + if field.name() == Some("file") { + let bytes = field + .bytes() + .await + .map_err(|_| AppError::BadRequest("Failed to read file".into()))?; + return Ok(bytes.to_vec()); + } + } + + Err(AppError::BadRequest("No 'file' field in multipart upload".into())) +} + +async fn fetch_pdf(url: &str) -> Result, AppError> { + let response = reqwest::get(url) + .await + .map_err(|e| AppError::FetchFailed(format!("Failed to fetch URL: {e}")))?; + + if !response.status().is_success() { + return Err(AppError::FetchFailed(format!( + "URL returned status {}", + response.status() + ))); + } + + let bytes = response + .bytes() + .await + .map_err(|e| AppError::FetchFailed(format!("Failed to read response: {e}")))?; + + if bytes.len() > 50 * 1024 * 1024 { + return Err(AppError::BadRequest("PDF exceeds 50MB limit".into())); + } + + Ok(bytes.to_vec()) +} diff --git a/agent-pdf/src/storage.rs b/agent-pdf/src/storage.rs new file mode 100644 index 0000000..f5bc931 --- /dev/null +++ b/agent-pdf/src/storage.rs @@ -0,0 +1,71 @@ +use aws_sdk_s3::Client; +use aws_sdk_s3::primitives::ByteStream; +use uuid::Uuid; + +use crate::config::Config; +use crate::error::AppError; + +pub struct Storage { + client: Client, + bucket: String, + public_url_prefix: String, +} + +/// Result of a successful upload. +pub struct UploadResult { + pub id: String, + pub public_url: String, +} + +impl Storage { + pub async fn new(config: &Config) -> Self { + let creds = aws_sdk_s3::config::Credentials::new( + std::env::var("SPACES_KEY").expect("SPACES_KEY must be set"), + std::env::var("SPACES_SECRET").expect("SPACES_SECRET must be set"), + None, + None, + "env", + ); + + let s3_config = aws_sdk_s3::Config::builder() + .endpoint_url(&config.spaces_endpoint) + .region(aws_sdk_s3::config::Region::new( + config.spaces_region.clone(), + )) + .credentials_provider(creds) + .force_path_style(false) + .build(); + + Self { + client: Client::from_conf(s3_config), + bucket: config.bucket.clone(), + public_url_prefix: config.public_url_prefix.clone(), + } + } + + /// Upload raw PDF bytes. Returns the file ID and public URL. + pub async fn upload(&self, bytes: Vec) -> Result { + // Basic PDF validation: check magic bytes + if bytes.len() < 5 || &bytes[..5] != b"%PDF-" { + return Err(AppError::BadRequest("Not a valid PDF file".into())); + } + + let id = Uuid::new_v4().to_string(); + let key = format!("uploads/{id}.pdf"); + + self.client + .put_object() + .bucket(&self.bucket) + .key(&key) + .body(ByteStream::from(bytes)) + .content_type("application/pdf") + .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead) + .send() + .await + .map_err(|e| AppError::StorageFailed(e.to_string()))?; + + let public_url = format!("{}/{key}", self.public_url_prefix); + + Ok(UploadResult { id, public_url }) + } +} From 050dec34a1def3f9fa408a97f4e7233b755b8135 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 13:43:49 +0100 Subject: [PATCH 02/53] fix: review fixes for agent-pdf - Remove fetch_pdf / reqwest (URL passthrough instead of re-upload) - Fix ConnectInfo extraction (into_make_service_with_connect_info) - Remove dead cleanup method from RateLimiter - Add Content-Disposition: attachment to S3 uploads - Remove unused aws-config and reqwest dependencies - Pin all dependencies to latest versions - Extract AgentResponse::new to deduplicate response building - Bump JSON body limit from 64KB to 1MB --- agent-pdf/Cargo.toml | 23 +++++----- agent-pdf/src/error.rs | 6 --- agent-pdf/src/main.rs | 10 ++++- agent-pdf/src/rate_limit.rs | 11 ----- agent-pdf/src/routes.rs | 86 ++++++++++++++----------------------- agent-pdf/src/storage.rs | 1 + 6 files changed, 51 insertions(+), 86 deletions(-) diff --git a/agent-pdf/Cargo.toml b/agent-pdf/Cargo.toml index 1e90c74..4a70c7b 100644 --- a/agent-pdf/Cargo.toml +++ b/agent-pdf/Cargo.toml @@ -4,19 +4,16 @@ version = "0.1.0" edition = "2021" [dependencies] -axum = "0.8" -tokio = { version = "1", features = ["full"] } -aws-sdk-s3 = "1" -aws-config = { version = "1", features = ["behavior-version-latest"] } -uuid = { version = "1", features = ["v4"] } -reqwest = { version = "0.12", features = ["stream"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "limit"] } -tracing = "0.1" -tracing-subscriber = "0.3" -dashmap = "6" +aws-sdk-s3 = "1.127.0" +axum = "0.8.8" +dashmap = "6.1.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.50.0", features = ["full"] } +tower-http = { version = "0.6.8", features = ["cors", "limit"] } +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +uuid = { version = "1.22.0", features = ["v4"] } [profile.release] opt-level = "z" diff --git a/agent-pdf/src/error.rs b/agent-pdf/src/error.rs index db619b6..547d8a3 100644 --- a/agent-pdf/src/error.rs +++ b/agent-pdf/src/error.rs @@ -4,13 +4,8 @@ use serde_json::json; #[derive(Debug)] pub enum AppError { - /// PDF too large or invalid BadRequest(String), - /// Rate limit exceeded RateLimited, - /// Failed to fetch URL - FetchFailed(String), - /// Storage error StorageFailed(String), } @@ -22,7 +17,6 @@ impl IntoResponse for AppError { StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Try again shortly.".into(), ), - AppError::FetchFailed(msg) => (StatusCode::BAD_GATEWAY, msg.clone()), AppError::StorageFailed(msg) => { tracing::error!("storage error: {msg}"); ( diff --git a/agent-pdf/src/main.rs b/agent-pdf/src/main.rs index 3356f90..c4fa776 100644 --- a/agent-pdf/src/main.rs +++ b/agent-pdf/src/main.rs @@ -5,6 +5,7 @@ mod routes; mod storage; use axum::Router; +use std::net::SocketAddr; use std::sync::Arc; use tower_http::cors::CorsLayer; use tower_http::limit::RequestBodyLimitLayer; @@ -32,12 +33,17 @@ async fn main() { let app = Router::new() .merge(routes::router()) .layer(CorsLayer::permissive()) - .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) // 50MB max + .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) .with_state(state); let addr = "0.0.0.0:8080"; tracing::info!("listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); } diff --git a/agent-pdf/src/rate_limit.rs b/agent-pdf/src/rate_limit.rs index c7292c6..c1b1378 100644 --- a/agent-pdf/src/rate_limit.rs +++ b/agent-pdf/src/rate_limit.rs @@ -33,15 +33,4 @@ impl RateLimiter { timestamps.push(now); true } - - /// Periodic cleanup of stale IPs. Call from a background task. - pub fn cleanup(&self) { - let now = Instant::now(); - let window = std::time::Duration::from_secs(60); - - self.buckets.retain(|_, timestamps| { - timestamps.retain(|t| now.duration_since(*t) < window); - !timestamps.is_empty() - }); - } } diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 464d8d5..4083e05 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -1,6 +1,6 @@ use axum::extract::{ConnectInfo, Multipart, State}; use axum::response::Json; -use axum::routing::post; +use axum::routing::{get, post}; use axum::Router; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; @@ -12,7 +12,7 @@ use crate::AppState; pub fn router() -> Router> { Router::new() .route("/agents", post(handle_agents)) - .route("/health", axum::routing::get(|| async { "ok" })) + .route("/health", get(|| async { "ok" })) } #[derive(Deserialize)] @@ -28,12 +28,23 @@ struct AgentResponse { react: String, } -/// POST /agents -/// Accepts either: -/// - JSON body: { "url": "https://..." } -/// - Multipart form: file field with PDF binary -/// -/// Returns embed codes for the editor. +impl AgentResponse { + fn new(id: String, pdf_url: &str, simplepdf_base: &str) -> Self { + let url = format!("{simplepdf_base}/editor?open={pdf_url}"); + let iframe = format!( + r#""# + ); + let react = format!(r#""#); + + Self { + id, + url, + iframe, + react, + } + } +} + async fn handle_agents( State(state): State>, ConnectInfo(addr): ConnectInfo, @@ -50,34 +61,25 @@ async fn handle_agents( .unwrap_or("") .to_string(); - let pdf_bytes = if content_type.starts_with("multipart/form-data") { - extract_multipart(request).await? + let base = &state.config.simplepdf_url; + + if content_type.starts_with("multipart/form-data") { + let pdf_bytes = extract_multipart(request).await?; + let result = state.storage.upload(pdf_bytes).await?; + + Ok(Json(AgentResponse::new(result.id, &result.public_url, base))) } else { - let body = axum::body::to_bytes(request.into_body(), 1024 * 64) + let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) .await .map_err(|_| AppError::BadRequest("Invalid request body".into()))?; let input: UrlInput = serde_json::from_slice(&body) - .map_err(|_| AppError::BadRequest("Expected JSON with 'url' field or multipart upload".into()))?; - - fetch_pdf(&input.url).await? - }; + .map_err(|_| { + AppError::BadRequest("Expected JSON with 'url' field or multipart upload".into()) + })?; - let result = state.storage.upload(pdf_bytes).await?; - let base = &state.config.simplepdf_url; - - let url = format!("{base}/editor?open={}", result.public_url); - let iframe = format!( - r#""# - ); - let react = format!(r#""#, result.public_url); - - Ok(Json(AgentResponse { - id: result.id, - url, - iframe, - react, - })) + Ok(Json(AgentResponse::new("url-passthrough".into(), &input.url, base))) + } } async fn extract_multipart(request: axum::extract::Request) -> Result, AppError> { @@ -101,27 +103,3 @@ async fn extract_multipart(request: axum::extract::Request) -> Result, A Err(AppError::BadRequest("No 'file' field in multipart upload".into())) } - -async fn fetch_pdf(url: &str) -> Result, AppError> { - let response = reqwest::get(url) - .await - .map_err(|e| AppError::FetchFailed(format!("Failed to fetch URL: {e}")))?; - - if !response.status().is_success() { - return Err(AppError::FetchFailed(format!( - "URL returned status {}", - response.status() - ))); - } - - let bytes = response - .bytes() - .await - .map_err(|e| AppError::FetchFailed(format!("Failed to read response: {e}")))?; - - if bytes.len() > 50 * 1024 * 1024 { - return Err(AppError::BadRequest("PDF exceeds 50MB limit".into())); - } - - Ok(bytes.to_vec()) -} diff --git a/agent-pdf/src/storage.rs b/agent-pdf/src/storage.rs index f5bc931..e4c9b16 100644 --- a/agent-pdf/src/storage.rs +++ b/agent-pdf/src/storage.rs @@ -59,6 +59,7 @@ impl Storage { .key(&key) .body(ByteStream::from(bytes)) .content_type("application/pdf") + .content_disposition("attachment") .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead) .send() .await From 40ef5efd0aaa31a262128c223d48b0b897b22899 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 13:50:19 +0100 Subject: [PATCH 03/53] chore: switch agent-pdf region from ams3 to nyc3 --- agent-pdf/.do/app.yaml | 8 ++++---- agent-pdf/src/config.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index 199b9cb..025ae3f 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -1,5 +1,5 @@ name: agent-pdf -region: ams +region: nyc services: - name: api github: @@ -25,13 +25,13 @@ services: value: agent-pdf - key: SPACES_ENDPOINT scope: RUN_TIME - value: https://ams3.digitaloceanspaces.com + value: https://nyc3.digitaloceanspaces.com - key: SPACES_REGION scope: RUN_TIME - value: ams3 + value: nyc3 - key: SPACES_PUBLIC_URL scope: RUN_TIME - value: https://agent-pdf.ams3.cdn.digitaloceanspaces.com + value: https://agent-pdf.nyc3.cdn.digitaloceanspaces.com - key: SIMPLEPDF_URL scope: RUN_TIME value: https://simplepdf.com diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index ab2f7c3..7a1d7b9 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -19,7 +19,7 @@ impl Config { Self { bucket: env("SPACES_BUCKET"), spaces_endpoint: env("SPACES_ENDPOINT"), - spaces_region: env_or("SPACES_REGION", "ams3"), + spaces_region: env_or("SPACES_REGION", "nyc3"), public_url_prefix: env("SPACES_PUBLIC_URL"), simplepdf_url: env_or("SIMPLEPDF_URL", "https://simplepdf.com"), rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") From 40c3b537678a9068e3f5fd55c3c704ed407ddbed Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:10:20 +0100 Subject: [PATCH 04/53] feat: add SKILL.md, companyIdentifier query param, and root skill endpoint - GET / serves SKILL.md as text/markdown for agent discovery - POST /agents accepts ?companyIdentifier to route to custom portals - Default editor: embed.simplepdf.com, with identifier: .simplepdf.com - Rename SIMPLEPDF_URL env to DEFAULT_EDITOR_HOST --- agent-pdf/.do/app.yaml | 4 +- agent-pdf/README.md | 35 +++++++++++----- agent-pdf/SKILL.md | 90 +++++++++++++++++++++++++++++++++++++++++ agent-pdf/src/config.rs | 18 ++++----- agent-pdf/src/routes.rs | 31 ++++++++++---- 5 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 agent-pdf/SKILL.md diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index 025ae3f..d3fe18d 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -32,9 +32,9 @@ services: - key: SPACES_PUBLIC_URL scope: RUN_TIME value: https://agent-pdf.nyc3.cdn.digitaloceanspaces.com - - key: SIMPLEPDF_URL + - key: DEFAULT_EDITOR_HOST scope: RUN_TIME - value: https://simplepdf.com + value: embed.simplepdf.com - key: RATE_LIMIT_PER_MIN scope: RUN_TIME value: "30" diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 3762632..1108d4b 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -1,24 +1,41 @@ # agent-pdf -Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), stores it in DO Spaces, returns ready-to-embed URLs. +Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), returns ready-to-embed editor URLs. + +Hosted at `ai.simplepdf.com`. The root endpoint (`GET /`) serves `SKILL.md` as `text/markdown` for agent discovery. ## Endpoints +### `GET /` + +Returns the SKILL.md file as `text/markdown`. Describes the API capabilities for AI agents and users. + ### `POST /agents` -Full PDF editor. Accepts JSON or multipart. +Accepts JSON or multipart. Returns editor embed codes. ```bash -# Via URL -curl -X POST https://your-app.ondigitalocean.app/agents \ +# Via URL (passthrough - no upload needed) +curl -X POST https://ai.simplepdf.com/agents \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/form.pdf"}' -# Via file upload -curl -X POST https://your-app.ondigitalocean.app/agents \ +# Via file upload (stored in DO Spaces, expires after 1hr) +curl -X POST https://ai.simplepdf.com/agents \ -F file=@document.pdf + +# With company-specific portal +curl -X POST "https://ai.simplepdf.com/agents?companyIdentifier=acme" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/form.pdf"}' ``` +#### Query parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `companyIdentifier` | No | Routes to `.simplepdf.com` instead of `embed.simplepdf.com` | + ## Deploy 1. Create a DO Spaces bucket named `agent-pdf` with a lifecycle rule (1hr expiry) @@ -29,9 +46,9 @@ curl -X POST https://your-app.ondigitalocean.app/agents \ ## Architecture ``` -Agent → POST /agents → Rust (upload to Spaces) → JSON response - ↓ -User clicks URL → simplepdf.com/editor?open=SPACES_URL → client-side editing +Agent → POST /agents → Rust (upload to Spaces or URL passthrough) → JSON response + ↓ +User clicks URL → .simplepdf.com/editor?open=PDF_URL → client-side editing ``` No database. No auth. No sessions. Bucket lifecycle handles cleanup. diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md new file mode 100644 index 0000000..9b14b12 --- /dev/null +++ b/agent-pdf/SKILL.md @@ -0,0 +1,90 @@ +--- +name: simplepdf +description: Edit and fill PDF documents. Use when the user wants to fill a PDF form, + add text/signatures/checkboxes/images to a PDF, or annotate a PDF. Accepts a PDF + URL or file upload and returns a ready-to-use editor link. Documents are processed + client-side and never stored on SimplePDF servers. +--- + +# SimplePDF - PDF Editor + +Edit and fill PDF documents directly in the browser. Add text, signatures, checkboxes, images, and more. + +## Endpoint + +`POST https://ai.simplepdf.com/agents` + +## Usage + +### From a URL + +When you have a PDF URL, send it as JSON: + +```bash +curl -X POST https://ai.simplepdf.com/agents \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/document.pdf"}' +``` + +### From a file + +When you have a PDF file, upload it as multipart: + +```bash +curl -X POST https://ai.simplepdf.com/agents \ + -F file=@document.pdf +``` + +### Company-specific editor + +If the user has a SimplePDF portal with a custom subdomain, pass it as a query parameter: + +```bash +curl -X POST "https://ai.simplepdf.com/agents?companyIdentifier=acme" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/form.pdf"}' +``` + +This routes to `acme.simplepdf.com` instead of the default editor. + +## Response + +```json +{ + "id": "abc-123", + "url": "https://embed.simplepdf.com/editor?open=https://example.com/form.pdf", + "iframe": "", + "react": "" +} +``` + +| Field | Description | +|----------|--------------------------------------------------| +| `id` | Unique identifier for the upload (or `url-passthrough` for URL inputs) | +| `url` | Direct link to open the PDF in the SimplePDF editor | +| `iframe` | HTML snippet to embed the editor in a page | +| `react` | React component snippet using `@simplepdf/react-embed-pdf` | + +## When to use each field + +- **`url`**: Present this to the user as a clickable link to edit/fill the PDF +- **`iframe`**: Use when embedding the editor in a web page +- **`react`**: Use when integrating into a React application + +## Supported operations + +Once the user opens the editor link, they can: + +- Fill form fields (text inputs, checkboxes, radio buttons, dropdowns) +- Add free text annotations anywhere on the page +- Draw or type signatures +- Add images and stamps +- Add checkboxes and checkmarks +- Highlight, underline, or strikethrough text +- Add sticky notes + +## Limits + +- Maximum PDF size: 50 MB (file uploads only) +- Uploaded files expire after 1 hour +- Rate limit: 30 requests per minute per IP diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index 7a1d7b9..3e4e756 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -1,16 +1,9 @@ pub struct Config { - /// DO Spaces bucket name pub bucket: String, - /// DO Spaces endpoint (e.g. https://ams3.digitaloceanspaces.com) pub spaces_endpoint: String, - /// DO Spaces region (e.g. ams3) pub spaces_region: String, - /// CDN or direct URL prefix for public access - /// e.g. https://agent-pdf.ams3.cdn.digitaloceanspaces.com pub public_url_prefix: String, - /// SimplePDF base URL - pub simplepdf_url: String, - /// Requests per IP per minute + pub default_editor_host: String, pub rate_limit_per_minute: u32, } @@ -21,12 +14,19 @@ impl Config { spaces_endpoint: env("SPACES_ENDPOINT"), spaces_region: env_or("SPACES_REGION", "nyc3"), public_url_prefix: env("SPACES_PUBLIC_URL"), - simplepdf_url: env_or("SIMPLEPDF_URL", "https://simplepdf.com"), + default_editor_host: env_or("DEFAULT_EDITOR_HOST", "embed.simplepdf.com"), rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") .parse() .expect("RATE_LIMIT_PER_MIN must be a number"), } } + + pub fn editor_base_url(&self, company_identifier: Option<&str>) -> String { + match company_identifier { + Some(id) => format!("https://{id}.simplepdf.com"), + None => format!("https://{}", self.default_editor_host), + } + } } fn env(key: &str) -> String { diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 4083e05..0ac2556 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -1,5 +1,6 @@ -use axum::extract::{ConnectInfo, Multipart, State}; -use axum::response::Json; +use axum::extract::{ConnectInfo, Multipart, Query, State}; +use axum::http::header; +use axum::response::{IntoResponse, Json}; use axum::routing::{get, post}; use axum::Router; use serde::{Deserialize, Serialize}; @@ -9,12 +10,25 @@ use std::sync::Arc; use crate::error::AppError; use crate::AppState; +const SKILL_MD: &str = include_str!("../SKILL.md"); + pub fn router() -> Router> { Router::new() + .route("/", get(serve_skill)) .route("/agents", post(handle_agents)) .route("/health", get(|| async { "ok" })) } +async fn serve_skill() -> impl IntoResponse { + ([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD) +} + +#[derive(Deserialize)] +struct AgentsQuery { + #[serde(rename = "companyIdentifier")] + company_identifier: Option, +} + #[derive(Deserialize)] struct UrlInput { url: String, @@ -29,8 +43,8 @@ struct AgentResponse { } impl AgentResponse { - fn new(id: String, pdf_url: &str, simplepdf_base: &str) -> Self { - let url = format!("{simplepdf_base}/editor?open={pdf_url}"); + fn new(id: String, pdf_url: &str, editor_base: &str) -> Self { + let url = format!("{editor_base}/editor?open={pdf_url}"); let iframe = format!( r#""# ); @@ -48,6 +62,7 @@ impl AgentResponse { async fn handle_agents( State(state): State>, ConnectInfo(addr): ConnectInfo, + Query(query): Query, request: axum::extract::Request, ) -> Result, AppError> { if !state.rate_limiter.check(addr.ip()) { @@ -61,13 +76,15 @@ async fn handle_agents( .unwrap_or("") .to_string(); - let base = &state.config.simplepdf_url; + let editor_base = state + .config + .editor_base_url(query.company_identifier.as_deref()); if content_type.starts_with("multipart/form-data") { let pdf_bytes = extract_multipart(request).await?; let result = state.storage.upload(pdf_bytes).await?; - Ok(Json(AgentResponse::new(result.id, &result.public_url, base))) + Ok(Json(AgentResponse::new(result.id, &result.public_url, &editor_base))) } else { let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) .await @@ -78,7 +95,7 @@ async fn handle_agents( AppError::BadRequest("Expected JSON with 'url' field or multipart upload".into()) })?; - Ok(Json(AgentResponse::new("url-passthrough".into(), &input.url, base))) + Ok(Json(AgentResponse::new("url-passthrough".into(), &input.url, &editor_base))) } } From 01177d4ac87b220b33d1c54996f18b65a7ee4c0c Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:13:41 +0100 Subject: [PATCH 05/53] fix: address code review findings - Enable axum multipart feature (required for Multipart extractor) - Make rate limiter proxy-aware via X-Forwarded-For header - URL-encode the open parameter to handle signed URLs correctly - Fix react snippet to use EmbedPDF with documentURL prop --- agent-pdf/Cargo.toml | 2 +- agent-pdf/SKILL.md | 4 ++-- agent-pdf/src/routes.rs | 38 ++++++++++++++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/agent-pdf/Cargo.toml b/agent-pdf/Cargo.toml index 4a70c7b..c2c5484 100644 --- a/agent-pdf/Cargo.toml +++ b/agent-pdf/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] aws-sdk-s3 = "1.127.0" -axum = "0.8.8" +axum = { version = "0.8.8", features = ["multipart"] } dashmap = "6.1.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 9b14b12..47ba031 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -54,7 +54,7 @@ This routes to `acme.simplepdf.com` instead of the default editor. "id": "abc-123", "url": "https://embed.simplepdf.com/editor?open=https://example.com/form.pdf", "iframe": "", - "react": "" + "react": "" } ``` @@ -63,7 +63,7 @@ This routes to `acme.simplepdf.com` instead of the default editor. | `id` | Unique identifier for the upload (or `url-passthrough` for URL inputs) | | `url` | Direct link to open the PDF in the SimplePDF editor | | `iframe` | HTML snippet to embed the editor in a page | -| `react` | React component snippet using `@simplepdf/react-embed-pdf` | +| `react` | React component snippet using `EmbedPDF` from `@simplepdf/react-embed-pdf` | ## When to use each field diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 0ac2556..5870c4d 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -4,7 +4,7 @@ use axum::response::{IntoResponse, Json}; use axum::routing::{get, post}; use axum::Router; use serde::{Deserialize, Serialize}; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use crate::error::AppError; @@ -23,6 +23,31 @@ async fn serve_skill() -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD) } +fn client_ip(request: &axum::extract::Request, fallback: IpAddr) -> IpAddr { + request + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(fallback) +} + +fn url_encode(input: &str) -> String { + let mut encoded = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{byte:02X}")); + } + } + } + encoded +} + #[derive(Deserialize)] struct AgentsQuery { #[serde(rename = "companyIdentifier")] @@ -44,11 +69,14 @@ struct AgentResponse { impl AgentResponse { fn new(id: String, pdf_url: &str, editor_base: &str) -> Self { - let url = format!("{editor_base}/editor?open={pdf_url}"); + let encoded_pdf_url = url_encode(pdf_url); + let url = format!("{editor_base}/editor?open={encoded_pdf_url}"); let iframe = format!( r#""# ); - let react = format!(r#""#); + let react = format!( + r#""# + ); Self { id, @@ -65,7 +93,9 @@ async fn handle_agents( Query(query): Query, request: axum::extract::Request, ) -> Result, AppError> { - if !state.rate_limiter.check(addr.ip()) { + let ip = client_ip(&request, addr.ip()); + + if !state.rate_limiter.check(ip) { return Err(AppError::RateLimited); } From 6d88d1671146db5586719c24bfb9d0c853501335 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:20:14 +0100 Subject: [PATCH 06/53] chore: drop /agents slug, use root path for GET and POST --- agent-pdf/README.md | 16 ++++++++-------- agent-pdf/SKILL.md | 8 ++++---- agent-pdf/src/routes.rs | 3 +-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 1108d4b..dcfcea1 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -2,30 +2,30 @@ Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), returns ready-to-embed editor URLs. -Hosted at `ai.simplepdf.com`. The root endpoint (`GET /`) serves `SKILL.md` as `text/markdown` for agent discovery. +Hosted at `agents.simplepdf.com`. `GET /` serves the skill description, `POST /` handles PDF submissions. ## Endpoints ### `GET /` -Returns the SKILL.md file as `text/markdown`. Describes the API capabilities for AI agents and users. +Returns `SKILL.md` as `text/markdown`. Describes the API capabilities for AI agents and users. -### `POST /agents` +### `POST /` Accepts JSON or multipart. Returns editor embed codes. ```bash # Via URL (passthrough - no upload needed) -curl -X POST https://ai.simplepdf.com/agents \ +curl -X POST https://agents.simplepdf.com \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/form.pdf"}' # Via file upload (stored in DO Spaces, expires after 1hr) -curl -X POST https://ai.simplepdf.com/agents \ +curl -X POST https://agents.simplepdf.com \ -F file=@document.pdf # With company-specific portal -curl -X POST "https://ai.simplepdf.com/agents?companyIdentifier=acme" \ +curl -X POST "https://agents.simplepdf.com?companyIdentifier=acme" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/form.pdf"}' ``` @@ -46,8 +46,8 @@ curl -X POST "https://ai.simplepdf.com/agents?companyIdentifier=acme" \ ## Architecture ``` -Agent → POST /agents → Rust (upload to Spaces or URL passthrough) → JSON response - ↓ +Agent → POST / → Rust (upload to Spaces or URL passthrough) → JSON response + ↓ User clicks URL → .simplepdf.com/editor?open=PDF_URL → client-side editing ``` diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 47ba031..2f1bd8e 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -12,7 +12,7 @@ Edit and fill PDF documents directly in the browser. Add text, signatures, check ## Endpoint -`POST https://ai.simplepdf.com/agents` +`POST https://agents.simplepdf.com` ## Usage @@ -21,7 +21,7 @@ Edit and fill PDF documents directly in the browser. Add text, signatures, check When you have a PDF URL, send it as JSON: ```bash -curl -X POST https://ai.simplepdf.com/agents \ +curl -X POST https://agents.simplepdf.com \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/document.pdf"}' ``` @@ -31,7 +31,7 @@ curl -X POST https://ai.simplepdf.com/agents \ When you have a PDF file, upload it as multipart: ```bash -curl -X POST https://ai.simplepdf.com/agents \ +curl -X POST https://agents.simplepdf.com \ -F file=@document.pdf ``` @@ -40,7 +40,7 @@ curl -X POST https://ai.simplepdf.com/agents \ If the user has a SimplePDF portal with a custom subdomain, pass it as a query parameter: ```bash -curl -X POST "https://ai.simplepdf.com/agents?companyIdentifier=acme" \ +curl -X POST "https://agents.simplepdf.com?companyIdentifier=acme" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/form.pdf"}' ``` diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 5870c4d..9ba813a 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -14,8 +14,7 @@ const SKILL_MD: &str = include_str!("../SKILL.md"); pub fn router() -> Router> { Router::new() - .route("/", get(serve_skill)) - .route("/agents", post(handle_agents)) + .route("/", get(serve_skill).post(handle_agents)) .route("/health", get(|| async { "ok" })) } From e85f6de3376e422fc0269aa7b59a25865f6d382d Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:29:02 +0100 Subject: [PATCH 07/53] feat: review fixes, GET-based URL API, CI workflow - Enable axum multipart feature (build fix) - Fix tracing_subscriber::fmt::init() (build fix) - Proxy-aware rate limiting with TRUST_PROXY env + bounded bucket eviction - Validate companyIdentifier (subdomain pattern) and url (http/https only) - HTML-escape all values in iframe/react snippets - Include companyIdentifier in react snippet when present - Correct privacy statement: file uploads stored temporarily (1hr) - URL case is now GET /?url=... (no POST needed for URL passthrough) - POST / reserved for file uploads only - Rename agents.simplepdf.com -> agent.simplepdf.com - Add Cargo.lock for reproducible builds - Add .gitignore for target/ - Add GitHub Actions workflow (fmt, clippy, build) --- .github/workflows/agent-pdf.yaml | 50 + agent-pdf/.gitignore | 1 + agent-pdf/Cargo.lock | 2677 ++++++++++++++++++++++++++++++ agent-pdf/README.md | 37 +- agent-pdf/SKILL.md | 51 +- agent-pdf/src/config.rs | 2 + agent-pdf/src/main.rs | 2 +- agent-pdf/src/rate_limit.rs | 18 +- agent-pdf/src/routes.rs | 220 ++- agent-pdf/src/storage.rs | 2 +- 10 files changed, 2940 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/agent-pdf.yaml create mode 100644 agent-pdf/.gitignore create mode 100644 agent-pdf/Cargo.lock diff --git a/.github/workflows/agent-pdf.yaml b/.github/workflows/agent-pdf.yaml new file mode 100644 index 0000000..69cb744 --- /dev/null +++ b/.github/workflows/agent-pdf.yaml @@ -0,0 +1,50 @@ +name: Agent PDF + +on: + push: + branches: + - main + paths: + - agent-pdf/** + - .github/workflows/agent-pdf.yaml + pull_request: + branches: + - main + paths: + - agent-pdf/** + - .github/workflows/agent-pdf.yaml + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: agent-pdf + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + agent-pdf/target + key: ${{ runner.os }}-cargo-${{ hashFiles('agent-pdf/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Build + run: cargo build diff --git a/agent-pdf/.gitignore b/agent-pdf/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/agent-pdf/.gitignore @@ -0,0 +1 @@ +/target diff --git a/agent-pdf/Cargo.lock b/agent-pdf/Cargo.lock new file mode 100644 index 0000000..6d5acce --- /dev/null +++ b/agent-pdf/Cargo.lock @@ -0,0 +1,2677 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agent-pdf" +version = "0.1.0" +dependencies = [ + "aws-sdk-s3", + "axum", + "dashmap", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.127.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151783f64e0dcddeb4965d08e36c276b4400a46caa88805a2e36d497deaf031a" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6750f3dd509b0694a4377f0293ed2f9630d710b1cebe281fa8bac8f099f88bc6" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin 0.10.0", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/agent-pdf/README.md b/agent-pdf/README.md index dcfcea1..486d2d1 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -2,38 +2,40 @@ Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), returns ready-to-embed editor URLs. -Hosted at `agents.simplepdf.com`. `GET /` serves the skill description, `POST /` handles PDF submissions. +Hosted at `agent.simplepdf.com`. `GET /` without params serves the SKILL.md. `GET /?url=...` returns editor links. `POST /` handles file uploads. ## Endpoints ### `GET /` -Returns `SKILL.md` as `text/markdown`. Describes the API capabilities for AI agents and users. +Without `url` param: returns `SKILL.md` as `text/markdown` for agent discovery. + +With `url` param: returns JSON with editor embed codes. + +```bash +# URL passthrough (no upload needed) +curl "https://agent.simplepdf.com?url=https://example.com/form.pdf" + +# With company-specific portal +curl "https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme" +``` ### `POST /` -Accepts JSON or multipart. Returns editor embed codes. +File upload via multipart. Stored in DO Spaces (expires after 1hr). ```bash -# Via URL (passthrough - no upload needed) -curl -X POST https://agents.simplepdf.com \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com/form.pdf"}' - -# Via file upload (stored in DO Spaces, expires after 1hr) -curl -X POST https://agents.simplepdf.com \ - -F file=@document.pdf +curl -X POST https://agent.simplepdf.com -F file=@document.pdf # With company-specific portal -curl -X POST "https://agents.simplepdf.com?companyIdentifier=acme" \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com/form.pdf"}' +curl -X POST "https://agent.simplepdf.com?companyIdentifier=acme" -F file=@document.pdf ``` -#### Query parameters +### Query parameters | Parameter | Required | Description | |-----------|----------|-------------| +| `url` | GET only | PDF URL to open in the editor | | `companyIdentifier` | No | Routes to `.simplepdf.com` instead of `embed.simplepdf.com` | ## Deploy @@ -46,8 +48,9 @@ curl -X POST "https://agents.simplepdf.com?companyIdentifier=acme" \ ## Architecture ``` -Agent → POST / → Rust (upload to Spaces or URL passthrough) → JSON response - ↓ +Agent → GET /?url=PDF_URL → JSON response (passthrough) +Agent → POST / (file) → Rust (upload to Spaces) → JSON response + ↓ User clicks URL → .simplepdf.com/editor?open=PDF_URL → client-side editing ``` diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 2f1bd8e..2e9f91d 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -2,47 +2,38 @@ name: simplepdf description: Edit and fill PDF documents. Use when the user wants to fill a PDF form, add text/signatures/checkboxes/images to a PDF, or annotate a PDF. Accepts a PDF - URL or file upload and returns a ready-to-use editor link. Documents are processed - client-side and never stored on SimplePDF servers. + URL or file upload and returns a ready-to-use editor link. URL inputs are passed + through directly. File uploads are stored temporarily (1 hour) then deleted. --- # SimplePDF - PDF Editor Edit and fill PDF documents directly in the browser. Add text, signatures, checkboxes, images, and more. -## Endpoint +## From a URL -`POST https://agents.simplepdf.com` +Pass the PDF URL as a query parameter: -## Usage - -### From a URL - -When you have a PDF URL, send it as JSON: - -```bash -curl -X POST https://agents.simplepdf.com \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com/document.pdf"}' +``` +GET https://agent.simplepdf.com?url=https://example.com/document.pdf ``` -### From a file +Returns JSON with editor links. No POST needed. -When you have a PDF file, upload it as multipart: +## From a file + +Upload a PDF file as multipart: ```bash -curl -X POST https://agents.simplepdf.com \ - -F file=@document.pdf +curl -X POST https://agent.simplepdf.com -F file=@document.pdf ``` -### Company-specific editor +## Company-specific editor -If the user has a SimplePDF portal with a custom subdomain, pass it as a query parameter: +Add `companyIdentifier` to route to a custom portal: -```bash -curl -X POST "https://agents.simplepdf.com?companyIdentifier=acme" \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com/form.pdf"}' +``` +GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme ``` This routes to `acme.simplepdf.com` instead of the default editor. @@ -51,8 +42,8 @@ This routes to `acme.simplepdf.com` instead of the default editor. ```json { - "id": "abc-123", - "url": "https://embed.simplepdf.com/editor?open=https://example.com/form.pdf", + "id": "url-passthrough", + "url": "https://embed.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf", "iframe": "", "react": "" } @@ -71,6 +62,12 @@ This routes to `acme.simplepdf.com` instead of the default editor. - **`iframe`**: Use when embedding the editor in a web page - **`react`**: Use when integrating into a React application +## Privacy + +- **URL input**: The PDF URL is passed directly to the browser-based editor. The PDF is never downloaded or stored by this service. +- **File upload**: The PDF is temporarily stored for up to 1 hour to make it accessible to the browser-based editor, then automatically deleted. +- **Editing**: All PDF editing happens client-side in the browser. The edited document is never sent to SimplePDF servers. + ## Supported operations Once the user opens the editor link, they can: @@ -88,3 +85,5 @@ Once the user opens the editor link, they can: - Maximum PDF size: 50 MB (file uploads only) - Uploaded files expire after 1 hour - Rate limit: 30 requests per minute per IP +- URL must start with http:// or https:// +- companyIdentifier must be alphanumeric with hyphens (max 63 chars) diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index 3e4e756..ef0ea41 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -5,6 +5,7 @@ pub struct Config { pub public_url_prefix: String, pub default_editor_host: String, pub rate_limit_per_minute: u32, + pub trust_proxy: bool, } impl Config { @@ -18,6 +19,7 @@ impl Config { rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") .parse() .expect("RATE_LIMIT_PER_MIN must be a number"), + trust_proxy: env_or("TRUST_PROXY", "true") == "true", } } diff --git a/agent-pdf/src/main.rs b/agent-pdf/src/main.rs index c4fa776..da036f1 100644 --- a/agent-pdf/src/main.rs +++ b/agent-pdf/src/main.rs @@ -18,7 +18,7 @@ pub struct AppState { #[tokio::main] async fn main() { - tracing_subscriber::init(); + tracing_subscriber::fmt::init(); let config = config::Config::from_env(); let storage = storage::Storage::new(&config).await; diff --git a/agent-pdf/src/rate_limit.rs b/agent-pdf/src/rate_limit.rs index c1b1378..b392f74 100644 --- a/agent-pdf/src/rate_limit.rs +++ b/agent-pdf/src/rate_limit.rs @@ -2,6 +2,8 @@ use dashmap::DashMap; use std::net::IpAddr; use std::time::Instant; +const MAX_TRACKED_IPS: usize = 10_000; + pub struct RateLimiter { max_per_minute: u32, buckets: DashMap>, @@ -15,15 +17,17 @@ impl RateLimiter { } } - /// Returns true if the request should be allowed. pub fn check(&self, ip: IpAddr) -> bool { + if self.buckets.len() >= MAX_TRACKED_IPS { + self.evict_stale(); + } + let now = Instant::now(); let window = std::time::Duration::from_secs(60); let mut entry = self.buckets.entry(ip).or_default(); let timestamps = entry.value_mut(); - // Prune old entries timestamps.retain(|t| now.duration_since(*t) < window); if timestamps.len() >= self.max_per_minute as usize { @@ -33,4 +37,14 @@ impl RateLimiter { timestamps.push(now); true } + + fn evict_stale(&self) { + let now = Instant::now(); + let window = std::time::Duration::from_secs(60); + + self.buckets.retain(|_, timestamps| { + timestamps.retain(|t| now.duration_since(*t) < window); + !timestamps.is_empty() + }); + } } diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 9ba813a..076171a 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -1,7 +1,7 @@ -use axum::extract::{ConnectInfo, Multipart, Query, State}; +use axum::extract::{ConnectInfo, FromRequest, Multipart, Query, State}; use axum::http::header; use axum::response::{IntoResponse, Json}; -use axum::routing::{get, post}; +use axum::routing::get; use axum::Router; use serde::{Deserialize, Serialize}; use std::net::{IpAddr, SocketAddr}; @@ -14,48 +14,21 @@ const SKILL_MD: &str = include_str!("../SKILL.md"); pub fn router() -> Router> { Router::new() - .route("/", get(serve_skill).post(handle_agents)) + .route("/", get(handle_get).post(handle_upload)) .route("/health", get(|| async { "ok" })) } -async fn serve_skill() -> impl IntoResponse { - ([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD) -} - -fn client_ip(request: &axum::extract::Request, fallback: IpAddr) -> IpAddr { - request - .headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) - .and_then(|v| v.trim().parse::().ok()) - .unwrap_or(fallback) -} - -fn url_encode(input: &str) -> String { - let mut encoded = String::with_capacity(input.len()); - for byte in input.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - _ => { - encoded.push_str(&format!("%{byte:02X}")); - } - } - } - encoded -} - #[derive(Deserialize)] -struct AgentsQuery { +struct GetQuery { + url: Option, #[serde(rename = "companyIdentifier")] company_identifier: Option, } #[derive(Deserialize)] -struct UrlInput { - url: String, +struct UploadQuery { + #[serde(rename = "companyIdentifier")] + company_identifier: Option, } #[derive(Serialize)] @@ -67,15 +40,23 @@ struct AgentResponse { } impl AgentResponse { - fn new(id: String, pdf_url: &str, editor_base: &str) -> Self { + fn new(id: String, pdf_url: &str, editor_base: &str, company_identifier: Option<&str>) -> Self { let encoded_pdf_url = url_encode(pdf_url); let url = format!("{editor_base}/editor?open={encoded_pdf_url}"); + let escaped_url = escape_html(&url); let iframe = format!( - r#""# - ); - let react = format!( - r#""# + r#""# ); + let escaped_pdf_url = escape_html(pdf_url); + let react = match company_identifier { + Some(id) => { + let escaped_id = escape_html(id); + format!( + r#""# + ) + } + None => format!(r#""#), + }; Self { id, @@ -86,53 +67,77 @@ impl AgentResponse { } } -async fn handle_agents( +async fn handle_get( State(state): State>, ConnectInfo(addr): ConnectInfo, - Query(query): Query, + Query(query): Query, request: axum::extract::Request, -) -> Result, AppError> { - let ip = client_ip(&request, addr.ip()); +) -> Result { + let pdf_url = match query.url { + None => { + return Ok(( + [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], + SKILL_MD, + ) + .into_response()); + } + Some(url) => url, + }; + let ip = client_ip(&request, addr.ip(), state.config.trust_proxy); if !state.rate_limiter.check(ip) { return Err(AppError::RateLimited); } - let content_type = request - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - - let editor_base = state - .config - .editor_base_url(query.company_identifier.as_deref()); - - if content_type.starts_with("multipart/form-data") { - let pdf_bytes = extract_multipart(request).await?; - let result = state.storage.upload(pdf_bytes).await?; + if !is_valid_url(&pdf_url) { + return Err(AppError::BadRequest( + "url must start with http:// or https://".into(), + )); + } - Ok(Json(AgentResponse::new(result.id, &result.public_url, &editor_base))) - } else { - let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) - .await - .map_err(|_| AppError::BadRequest("Invalid request body".into()))?; + let company_identifier = validate_company_identifier(query.company_identifier.as_deref())?; + let editor_base = state.config.editor_base_url(company_identifier); - let input: UrlInput = serde_json::from_slice(&body) - .map_err(|_| { - AppError::BadRequest("Expected JSON with 'url' field or multipart upload".into()) - })?; + Ok(Json(AgentResponse::new( + "url-passthrough".into(), + &pdf_url, + &editor_base, + company_identifier, + )) + .into_response()) +} - Ok(Json(AgentResponse::new("url-passthrough".into(), &input.url, &editor_base))) +async fn handle_upload( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + Query(query): Query, + request: axum::extract::Request, +) -> Result, AppError> { + let ip = client_ip(&request, addr.ip(), state.config.trust_proxy); + if !state.rate_limiter.check(ip) { + return Err(AppError::RateLimited); } -} -async fn extract_multipart(request: axum::extract::Request) -> Result, AppError> { - let mut multipart = Multipart::from_request(request, &()) + let company_identifier = validate_company_identifier(query.company_identifier.as_deref())?; + let editor_base = state.config.editor_base_url(company_identifier); + + let multipart = Multipart::from_request(request, &state) .await - .map_err(|_| AppError::BadRequest("Invalid multipart data".into()))?; + .map_err(|_| { + AppError::BadRequest("Expected multipart/form-data with a 'file' field".into()) + })?; + let pdf_bytes = extract_multipart(multipart).await?; + let result = state.storage.upload(pdf_bytes).await?; + + Ok(Json(AgentResponse::new( + result.id, + &result.public_url, + &editor_base, + company_identifier, + ))) +} +async fn extract_multipart(mut multipart: Multipart) -> Result, AppError> { while let Some(field) = multipart .next_field() .await @@ -147,5 +152,74 @@ async fn extract_multipart(request: axum::extract::Request) -> Result, A } } - Err(AppError::BadRequest("No 'file' field in multipart upload".into())) + Err(AppError::BadRequest( + "No 'file' field in multipart upload".into(), + )) +} + +fn client_ip(request: &axum::extract::Request, fallback: IpAddr, trust_proxy: bool) -> IpAddr { + if !trust_proxy { + return fallback; + } + + request + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(fallback) +} + +fn url_encode(input: &str) -> String { + let mut encoded = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{byte:02X}")); + } + } + } + encoded +} + +fn escape_html(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +fn is_valid_subdomain(identifier: &str) -> bool { + !identifier.is_empty() + && identifier.len() <= 63 + && identifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + && !identifier.starts_with('-') + && !identifier.ends_with('-') +} + +fn is_valid_url(url: &str) -> bool { + url.starts_with("https://") || url.starts_with("http://") +} + +fn validate_company_identifier(identifier: Option<&str>) -> Result, AppError> { + match identifier { + Some(id) if !is_valid_subdomain(id) => Err(AppError::BadRequest( + "companyIdentifier must be alphanumeric with hyphens (max 63 chars)".into(), + )), + other => Ok(other), + } } diff --git a/agent-pdf/src/storage.rs b/agent-pdf/src/storage.rs index e4c9b16..a5a7a0b 100644 --- a/agent-pdf/src/storage.rs +++ b/agent-pdf/src/storage.rs @@ -1,5 +1,5 @@ -use aws_sdk_s3::Client; use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::Client; use uuid::Uuid; use crate::config::Config; From 8286549f12bec6e78c5a7aa503ed612f952dcf4a Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:30:33 +0100 Subject: [PATCH 08/53] fix: remove unsupported operations from SKILL.md --- agent-pdf/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 2e9f91d..54fdfde 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -75,10 +75,8 @@ Once the user opens the editor link, they can: - Fill form fields (text inputs, checkboxes, radio buttons, dropdowns) - Add free text annotations anywhere on the page - Draw or type signatures -- Add images and stamps +- Add images - Add checkboxes and checkmarks -- Highlight, underline, or strikethrough text -- Add sticky notes ## Limits From 827e0def93d0b6863786a138624e6750ce8383a1 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:31:06 +0100 Subject: [PATCH 09/53] fix: clarify companyIdentifier must be a valid SimplePDF identifier --- agent-pdf/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 54fdfde..503298d 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -84,4 +84,4 @@ Once the user opens the editor link, they can: - Uploaded files expire after 1 hour - Rate limit: 30 requests per minute per IP - URL must start with http:// or https:// -- companyIdentifier must be alphanumeric with hyphens (max 63 chars) +- companyIdentifier must be a valid SimplePDF portal identifier From df200e0fc8408f1ce590ee43283e11382d0406e8 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:33:57 +0100 Subject: [PATCH 10/53] docs: improve SKILL.md with presentation guidance and portal features --- agent-pdf/SKILL.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 503298d..49417dc 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -28,9 +28,17 @@ Upload a PDF file as multipart: curl -X POST https://agent.simplepdf.com -F file=@document.pdf ``` +## Presenting the result to the user + +Always present the `url` field as a clickable link. This is the primary way users will open the editor. + +If you have access to a browser automation tool (e.g. agent-browser, Playwright, chrome-devtools MCP server), offer to open the `url` directly so the user can edit the PDF without leaving the conversation. + +The `iframe` and `react` fields are for developers embedding the editor in a web application. Only present these when the user is building a web app. + ## Company-specific editor -Add `companyIdentifier` to route to a custom portal: +Add `companyIdentifier` to route to a custom SimplePDF portal: ``` GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme @@ -38,6 +46,16 @@ GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifi This routes to `acme.simplepdf.com` instead of the default editor. +### Portal features + +When using a `companyIdentifier`, the portal owner has access to: + +- **Email notifications**: receive an email each time a user submits a filled PDF +- **Webhook notifications**: receive submissions via webhook to integrate with any backend +- **Bring Your Own Storage (BYOS)**: route submitted PDFs directly to the company's own S3 or Azure storage bucket + +These features are configured by the portal owner in the SimplePDF admin console. No additional API parameters are needed - they apply automatically when the `companyIdentifier` is set. + ## Response ```json @@ -53,15 +71,9 @@ This routes to `acme.simplepdf.com` instead of the default editor. |----------|--------------------------------------------------| | `id` | Unique identifier for the upload (or `url-passthrough` for URL inputs) | | `url` | Direct link to open the PDF in the SimplePDF editor | -| `iframe` | HTML snippet to embed the editor in a page | +| `iframe` | HTML snippet to embed the editor in a web page | | `react` | React component snippet using `EmbedPDF` from `@simplepdf/react-embed-pdf` | -## When to use each field - -- **`url`**: Present this to the user as a clickable link to edit/fill the PDF -- **`iframe`**: Use when embedding the editor in a web page -- **`react`**: Use when integrating into a React application - ## Privacy - **URL input**: The PDF URL is passed directly to the browser-based editor. The PDF is never downloaded or stored by this service. From 3f00fb3e9baa9f647be17db31776e03b7ac90d2c Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:36:51 +0100 Subject: [PATCH 11/53] docs: rewrite SKILL.md with direct URL construction and multi-language examples --- agent-pdf/SKILL.md | 61 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 49417dc..1a0dc44 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -10,24 +10,69 @@ description: Edit and fill PDF documents. Use when the user wants to fill a PDF Edit and fill PDF documents directly in the browser. Add text, signatures, checkboxes, images, and more. +## How it works + +The SimplePDF editor opens any PDF via a URL in this format: + +``` +https://.simplepdf.com/editor?open= +``` + +Where `` is either `embed` (default) or a company-specific portal identifier. + +For PDFs already hosted at a URL, you can construct this link directly without any API call. The API at `agent.simplepdf.com` is a convenience layer that builds these links for you and handles file uploads for PDFs that are not hosted anywhere. + ## From a URL -Pass the PDF URL as a query parameter: +If the PDF is already accessible at a URL, you have two options: + +### Option 1: Construct the editor URL directly (no API call needed) + +URL-encode the PDF URL and append it to the editor base: + +``` +https://embed.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf +``` + +### Option 2: Use the API ``` -GET https://agent.simplepdf.com?url=https://example.com/document.pdf +GET https://agent.simplepdf.com?url=https://example.com/form.pdf ``` -Returns JSON with editor links. No POST needed. +Returns JSON with the editor URL and embed snippets. ## From a file -Upload a PDF file as multipart: +If the PDF is a local file (not hosted anywhere), upload it to the API. The file is stored temporarily (1 hour) to make it accessible to the browser-based editor. + +### Shell ```bash curl -X POST https://agent.simplepdf.com -F file=@document.pdf ``` +### TypeScript + +```typescript +const form = new FormData(); +form.append("file", new Blob([pdfBytes], { type: "application/pdf" }), "document.pdf"); + +const response = await fetch("https://agent.simplepdf.com", { method: "POST", body: form }); +const { url } = await response.json(); +``` + +### Python + +```python +import requests + +with open("document.pdf", "rb") as f: + response = requests.post("https://agent.simplepdf.com", files={"file": f}) + +url = response.json()["url"] +``` + ## Presenting the result to the user Always present the `url` field as a clickable link. This is the primary way users will open the editor. @@ -41,10 +86,14 @@ The `iframe` and `react` fields are for developers embedding the editor in a web Add `companyIdentifier` to route to a custom SimplePDF portal: ``` -GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme +https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme ``` -This routes to `acme.simplepdf.com` instead of the default editor. +Or construct it directly: + +``` +https://acme.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf +``` ### Portal features From f2c8f087b8821a25ef219331109a34ccde5fa65a Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:38:13 +0100 Subject: [PATCH 12/53] docs: add React integration example to SKILL.md --- agent-pdf/SKILL.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 1a0dc44..799078d 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -121,7 +121,29 @@ These features are configured by the portal owner in the SimplePDF admin console | `id` | Unique identifier for the upload (or `url-passthrough` for URL inputs) | | `url` | Direct link to open the PDF in the SimplePDF editor | | `iframe` | HTML snippet to embed the editor in a web page | -| `react` | React component snippet using `EmbedPDF` from `@simplepdf/react-embed-pdf` | +| `react` | React component snippet (see example below) | + +### React integration + +Requires `@simplepdf/react-embed-pdf` (`npm install @simplepdf/react-embed-pdf`): + +```tsx +import { EmbedPDF } from "@simplepdf/react-embed-pdf"; + +const PDFEditor = ({ documentURL }: { documentURL: string }) => ( + +); +``` + +With a company-specific portal: + +```tsx + +``` ## Privacy From 3574fc6b99ab69d32c22dcb31df1048b673f4a28 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:39:17 +0100 Subject: [PATCH 13/53] docs: remove embed.simplepdf.com references, agent.simplepdf.com is the single entry point --- agent-pdf/SKILL.md | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 799078d..2ca3611 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -10,32 +10,8 @@ description: Edit and fill PDF documents. Use when the user wants to fill a PDF Edit and fill PDF documents directly in the browser. Add text, signatures, checkboxes, images, and more. -## How it works - -The SimplePDF editor opens any PDF via a URL in this format: - -``` -https://.simplepdf.com/editor?open= -``` - -Where `` is either `embed` (default) or a company-specific portal identifier. - -For PDFs already hosted at a URL, you can construct this link directly without any API call. The API at `agent.simplepdf.com` is a convenience layer that builds these links for you and handles file uploads for PDFs that are not hosted anywhere. - ## From a URL -If the PDF is already accessible at a URL, you have two options: - -### Option 1: Construct the editor URL directly (no API call needed) - -URL-encode the PDF URL and append it to the editor base: - -``` -https://embed.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf -``` - -### Option 2: Use the API - ``` GET https://agent.simplepdf.com?url=https://example.com/form.pdf ``` @@ -44,7 +20,7 @@ Returns JSON with the editor URL and embed snippets. ## From a file -If the PDF is a local file (not hosted anywhere), upload it to the API. The file is stored temporarily (1 hour) to make it accessible to the browser-based editor. +Upload a PDF file to the API. The file is stored temporarily (1 hour) to make it accessible to the browser-based editor. ### Shell @@ -86,13 +62,7 @@ The `iframe` and `react` fields are for developers embedding the editor in a web Add `companyIdentifier` to route to a custom SimplePDF portal: ``` -https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme -``` - -Or construct it directly: - -``` -https://acme.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf +GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme ``` ### Portal features @@ -110,7 +80,7 @@ These features are configured by the portal owner in the SimplePDF admin console ```json { "id": "url-passthrough", - "url": "https://embed.simplepdf.com/editor?open=https%3A%2F%2Fexample.com%2Fform.pdf", + "url": "https://agent.simplepdf.com/editor?open=...", "iframe": "", "react": "" } From f6e3c4d0866c23558fb448078508815c23cba9e5 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:39:54 +0100 Subject: [PATCH 14/53] docs: add legal disclaimer to SKILL.md --- agent-pdf/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 2ca3611..de42bf5 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -138,3 +138,7 @@ Once the user opens the editor link, they can: - Rate limit: 30 requests per minute per IP - URL must start with http:// or https:// - companyIdentifier must be a valid SimplePDF portal identifier + +## Legal + +SimplePDF is not responsible for the content of uploaded PDFs. By using this service, you agree that you have the right to process the documents you upload. For concerns or requests, contact support@simplepdf.com. From 4d507fc56ff6ba5e4f45d57e258ce63bee194114 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:41:55 +0100 Subject: [PATCH 15/53] chore: remove id field from API response --- agent-pdf/SKILL.md | 2 -- agent-pdf/src/routes.rs | 12 ++---------- agent-pdf/src/storage.rs | 4 +--- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index de42bf5..5c87c98 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -79,7 +79,6 @@ These features are configured by the portal owner in the SimplePDF admin console ```json { - "id": "url-passthrough", "url": "https://agent.simplepdf.com/editor?open=...", "iframe": "", "react": "" @@ -88,7 +87,6 @@ These features are configured by the portal owner in the SimplePDF admin console | Field | Description | |----------|--------------------------------------------------| -| `id` | Unique identifier for the upload (or `url-passthrough` for URL inputs) | | `url` | Direct link to open the PDF in the SimplePDF editor | | `iframe` | HTML snippet to embed the editor in a web page | | `react` | React component snippet (see example below) | diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index 076171a..fc5766b 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -33,14 +33,13 @@ struct UploadQuery { #[derive(Serialize)] struct AgentResponse { - id: String, url: String, iframe: String, react: String, } impl AgentResponse { - fn new(id: String, pdf_url: &str, editor_base: &str, company_identifier: Option<&str>) -> Self { + fn new(pdf_url: &str, editor_base: &str, company_identifier: Option<&str>) -> Self { let encoded_pdf_url = url_encode(pdf_url); let url = format!("{editor_base}/editor?open={encoded_pdf_url}"); let escaped_url = escape_html(&url); @@ -58,12 +57,7 @@ impl AgentResponse { None => format!(r#""#), }; - Self { - id, - url, - iframe, - react, - } + Self { url, iframe, react } } } @@ -99,7 +93,6 @@ async fn handle_get( let editor_base = state.config.editor_base_url(company_identifier); Ok(Json(AgentResponse::new( - "url-passthrough".into(), &pdf_url, &editor_base, company_identifier, @@ -130,7 +123,6 @@ async fn handle_upload( let result = state.storage.upload(pdf_bytes).await?; Ok(Json(AgentResponse::new( - result.id, &result.public_url, &editor_base, company_identifier, diff --git a/agent-pdf/src/storage.rs b/agent-pdf/src/storage.rs index a5a7a0b..868990d 100644 --- a/agent-pdf/src/storage.rs +++ b/agent-pdf/src/storage.rs @@ -11,9 +11,7 @@ pub struct Storage { public_url_prefix: String, } -/// Result of a successful upload. pub struct UploadResult { - pub id: String, pub public_url: String, } @@ -67,6 +65,6 @@ impl Storage { let public_url = format!("{}/{key}", self.public_url_prefix); - Ok(UploadResult { id, public_url }) + Ok(UploadResult { public_url }) } } From 24c37c99a034de7d46f791a8d7b35fcbb9309ccf Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:42:37 +0100 Subject: [PATCH 16/53] docs: add automatic form field detection to supported operations --- agent-pdf/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 5c87c98..86ae5ca 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -123,6 +123,7 @@ With a company-specific portal: Once the user opens the editor link, they can: +- Automatic detection of form fields - Fill form fields (text inputs, checkboxes, radio buttons, dropdowns) - Add free text annotations anywhere on the page - Draw or type signatures From c5dca08e6cef970da0b4ab98325f82c4c41b8631 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 14:47:51 +0100 Subject: [PATCH 17/53] fix: switch from buildpack to Dockerfile for DO App Platform Paketo Rust buildpack does not find Cargo.toml with source_dir. Dockerfile gives full control over the build. --- agent-pdf/.do/app.yaml | 2 +- agent-pdf/Dockerfile | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 agent-pdf/Dockerfile diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index d3fe18d..6df95ed 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -7,7 +7,7 @@ services: branch: main deploy_on_push: true source_dir: agent-pdf - buildpack: rust + dockerfile_path: Dockerfile instance_count: 1 instance_size_slug: basic-xs http_port: 8080 diff --git a/agent-pdf/Dockerfile b/agent-pdf/Dockerfile new file mode 100644 index 0000000..07ffc66 --- /dev/null +++ b/agent-pdf/Dockerfile @@ -0,0 +1,12 @@ +FROM rust:1 AS builder +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY SKILL.md ./ +RUN cargo build --release + +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/agent-pdf /usr/local/bin/agent-pdf +EXPOSE 8080 +CMD ["agent-pdf"] From b82c08e58645b08e7a317d259a95b299c0146b61 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:00:48 +0100 Subject: [PATCH 18/53] fix: add POST JSON path for sensitive URLs, secure proxy trust, unify hosts - POST with JSON body for signed/sensitive URLs (keeps them out of logs) - Default TRUST_PROXY to false, explicitly enable in production - Use rightmost X-Forwarded-For hop instead of leftmost - Remove agent.simplepdf.com from response example (implementation detail) - Document GET vs POST URL choice in SKILL.md - Add TRUST_PROXY to app.yaml reference config --- agent-pdf/.do/app.yaml | 3 ++ agent-pdf/SKILL.md | 20 +++++++++- agent-pdf/src/config.rs | 2 +- agent-pdf/src/routes.rs | 82 ++++++++++++++++++++++++++++++++--------- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index 6df95ed..f64d709 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -35,6 +35,9 @@ services: - key: DEFAULT_EDITOR_HOST scope: RUN_TIME value: embed.simplepdf.com + - key: TRUST_PROXY + scope: RUN_TIME + value: "true" - key: RATE_LIMIT_PER_MIN scope: RUN_TIME value: "30" diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 86ae5ca..e09fad9 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -12,11 +12,21 @@ Edit and fill PDF documents directly in the browser. Add text, signatures, check ## From a URL +For public PDF URLs, use GET with the URL as a query parameter: + ``` GET https://agent.simplepdf.com?url=https://example.com/form.pdf ``` -Returns JSON with the editor URL and embed snippets. +For signed or sensitive URLs (e.g. presigned S3 links), use POST with a JSON body to keep the URL out of logs and browser history: + +```bash +curl -X POST https://agent.simplepdf.com \ + -H "Content-Type: application/json" \ + -d '{"url": "https://s3.amazonaws.com/bucket/doc.pdf?X-Amz-Signature=..."}' +``` + +Both return JSON with the editor URL and embed snippets. ## From a file @@ -65,6 +75,12 @@ Add `companyIdentifier` to route to a custom SimplePDF portal: GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme ``` +Or in a POST JSON body: + +```json +{"url": "https://example.com/form.pdf", "companyIdentifier": "acme"} +``` + ### Portal features When using a `companyIdentifier`, the portal owner has access to: @@ -79,7 +95,7 @@ These features are configured by the portal owner in the SimplePDF admin console ```json { - "url": "https://agent.simplepdf.com/editor?open=...", + "url": "https://...", "iframe": "", "react": "" } diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index ef0ea41..d5c416e 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -19,7 +19,7 @@ impl Config { rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") .parse() .expect("RATE_LIMIT_PER_MIN must be a number"), - trust_proxy: env_or("TRUST_PROXY", "true") == "true", + trust_proxy: env_or("TRUST_PROXY", "false") == "true", } } diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index fc5766b..ee6dab7 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -14,7 +14,7 @@ const SKILL_MD: &str = include_str!("../SKILL.md"); pub fn router() -> Router> { Router::new() - .route("/", get(handle_get).post(handle_upload)) + .route("/", get(handle_get).post(handle_post)) .route("/health", get(|| async { "ok" })) } @@ -26,7 +26,14 @@ struct GetQuery { } #[derive(Deserialize)] -struct UploadQuery { +struct PostQuery { + #[serde(rename = "companyIdentifier")] + company_identifier: Option, +} + +#[derive(Deserialize)] +struct UrlInput { + url: String, #[serde(rename = "companyIdentifier")] company_identifier: Option, } @@ -100,10 +107,10 @@ async fn handle_get( .into_response()) } -async fn handle_upload( +async fn handle_post( State(state): State>, ConnectInfo(addr): ConnectInfo, - Query(query): Query, + Query(query): Query, request: axum::extract::Request, ) -> Result, AppError> { let ip = client_ip(&request, addr.ip(), state.config.trust_proxy); @@ -111,22 +118,61 @@ async fn handle_upload( return Err(AppError::RateLimited); } - let company_identifier = validate_company_identifier(query.company_identifier.as_deref())?; - let editor_base = state.config.editor_base_url(company_identifier); + let content_type = request + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); - let multipart = Multipart::from_request(request, &state) - .await - .map_err(|_| { - AppError::BadRequest("Expected multipart/form-data with a 'file' field".into()) + if content_type.starts_with("multipart/form-data") { + let company_identifier = validate_company_identifier(query.company_identifier.as_deref())?; + let editor_base = state.config.editor_base_url(company_identifier); + + let multipart = Multipart::from_request(request, &state) + .await + .map_err(|_| { + AppError::BadRequest("Expected multipart/form-data with a 'file' field".into()) + })?; + let pdf_bytes = extract_multipart(multipart).await?; + let result = state.storage.upload(pdf_bytes).await?; + + Ok(Json(AgentResponse::new( + &result.public_url, + &editor_base, + company_identifier, + ))) + } else { + let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) + .await + .map_err(|_| AppError::BadRequest("Invalid request body".into()))?; + + let input: UrlInput = serde_json::from_slice(&body).map_err(|_| { + AppError::BadRequest( + "Expected JSON with 'url' field or multipart/form-data with 'file' field".into(), + ) })?; - let pdf_bytes = extract_multipart(multipart).await?; - let result = state.storage.upload(pdf_bytes).await?; - Ok(Json(AgentResponse::new( - &result.public_url, - &editor_base, - company_identifier, - ))) + if !is_valid_url(&input.url) { + return Err(AppError::BadRequest( + "url must start with http:// or https://".into(), + )); + } + + let company_identifier = validate_company_identifier( + input + .company_identifier + .as_deref() + .or(query.company_identifier.as_deref()), + )?; + let editor_base = state.config.editor_base_url(company_identifier); + + Ok(Json(AgentResponse::new( + &input.url, + &editor_base, + company_identifier, + ))) + } } async fn extract_multipart(mut multipart: Multipart) -> Result, AppError> { @@ -158,7 +204,7 @@ fn client_ip(request: &axum::extract::Request, fallback: IpAddr, trust_proxy: bo .headers() .get("x-forwarded-for") .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) + .and_then(|v| v.rsplit(',').next()) .and_then(|v| v.trim().parse::().ok()) .unwrap_or(fallback) } From 206fcac852557833440e93016ba1b516914b7628 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:04:33 +0100 Subject: [PATCH 19/53] feat: unify default editor host to ai.simplepdf.com - DEFAULT_EDITOR_HOST now ai.simplepdf.com (was embed.simplepdf.com) - React snippet always includes companyIdentifier ("ai" as default) - All response fields (url, iframe, react) point to same host --- agent-pdf/.do/app.yaml | 2 +- agent-pdf/SKILL.md | 4 ++-- agent-pdf/src/config.rs | 2 +- agent-pdf/src/routes.rs | 13 ++++--------- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index f64d709..a0ee8f5 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -34,7 +34,7 @@ services: value: https://agent-pdf.nyc3.cdn.digitaloceanspaces.com - key: DEFAULT_EDITOR_HOST scope: RUN_TIME - value: embed.simplepdf.com + value: ai.simplepdf.com - key: TRUST_PROXY scope: RUN_TIME value: "true" diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index e09fad9..866a6eb 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -115,11 +115,11 @@ Requires `@simplepdf/react-embed-pdf` (`npm install @simplepdf/react-embed-pdf`) import { EmbedPDF } from "@simplepdf/react-embed-pdf"; const PDFEditor = ({ documentURL }: { documentURL: string }) => ( - + ); ``` -With a company-specific portal: +With a company-specific portal, replace `"ai"` with the portal identifier: ```tsx "# ); let escaped_pdf_url = escape_html(pdf_url); - let react = match company_identifier { - Some(id) => { - let escaped_id = escape_html(id); - format!( - r#""# - ) - } - None => format!(r#""#), - }; + let react_identifier = escape_html(company_identifier.unwrap_or("ai")); + let react = format!( + r#""# + ); Self { url, iframe, react } } From af637196e8171ca9a69e986a5b12462cfecc625d Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:06:30 +0100 Subject: [PATCH 20/53] chore: rename id variable to company_identifier for clarity --- agent-pdf/src/config.rs | 2 +- agent-pdf/src/routes.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index 5b427f3..b7f20f0 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -25,7 +25,7 @@ impl Config { pub fn editor_base_url(&self, company_identifier: Option<&str>) -> String { match company_identifier { - Some(id) => format!("https://{id}.simplepdf.com"), + Some(company_identifier) => format!("https://{company_identifier}.simplepdf.com"), None => format!("https://{}", self.default_editor_host), } } diff --git a/agent-pdf/src/routes.rs b/agent-pdf/src/routes.rs index d60b52c..d5a3219 100644 --- a/agent-pdf/src/routes.rs +++ b/agent-pdf/src/routes.rs @@ -250,9 +250,11 @@ fn is_valid_url(url: &str) -> bool { fn validate_company_identifier(identifier: Option<&str>) -> Result, AppError> { match identifier { - Some(id) if !is_valid_subdomain(id) => Err(AppError::BadRequest( - "companyIdentifier must be alphanumeric with hyphens (max 63 chars)".into(), - )), + Some(company_identifier) if !is_valid_subdomain(company_identifier) => { + Err(AppError::BadRequest( + "companyIdentifier must be alphanumeric with hyphens (max 63 chars)".into(), + )) + } other => Ok(other), } } From 296db84bd95b8dcb7b4f40990aa4145860bbbf9d Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:08:27 +0100 Subject: [PATCH 21/53] docs: rename Portal features to SimplePDF account features, add branding and help links --- agent-pdf/SKILL.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index 866a6eb..e47e27a 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -81,15 +81,16 @@ Or in a POST JSON body: {"url": "https://example.com/form.pdf", "companyIdentifier": "acme"} ``` -### Portal features +### SimplePDF account features -When using a `companyIdentifier`, the portal owner has access to: +When using a `companyIdentifier`, the account owner has access to: +- **Custom branding**: customize the editor appearance ([guide](https://simplepdf.com/help/how-to/customize-the-pdf-editor-and-add-branding)) - **Email notifications**: receive an email each time a user submits a filled PDF -- **Webhook notifications**: receive submissions via webhook to integrate with any backend -- **Bring Your Own Storage (BYOS)**: route submitted PDFs directly to the company's own S3 or Azure storage bucket +- **Webhook notifications**: receive submissions via webhook to integrate with any backend ([guide](https://simplepdf.com/help/how-to/configure-webhooks-pdf-form-submissions)) +- **Bring Your Own Storage (BYOS)**: route submitted PDFs directly to the company's own S3 or Azure storage bucket ([guide](https://simplepdf.com/help/how-to/use-your-own-s3-bucket-storage-for-pdf-form-submissions)) -These features are configured by the portal owner in the SimplePDF admin console. No additional API parameters are needed - they apply automatically when the `companyIdentifier` is set. +These features are configured in the SimplePDF admin console. No additional API parameters are needed - they apply automatically when the `companyIdentifier` is set. ## Response From e39375fc3fa927c00682c649b2c1ce9316996b6e Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:13:30 +0100 Subject: [PATCH 22/53] fix: pin builder to rust:1-bookworm to match runtime glibc --- agent-pdf/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-pdf/Dockerfile b/agent-pdf/Dockerfile index 07ffc66..e122ed1 100644 --- a/agent-pdf/Dockerfile +++ b/agent-pdf/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1 AS builder +FROM rust:1-bookworm AS builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY src ./src From e298c06023dcc49baba8007273678ce771d181da Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:31:18 +0100 Subject: [PATCH 23/53] fix: add behavior_version_latest to S3 config, rename env vars to S3_* --- agent-pdf/.do/app.yaml | 16 ++++++++-------- agent-pdf/src/config.rs | 19 +++++++++++-------- agent-pdf/src/storage.rs | 21 +++++++++------------ 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/agent-pdf/.do/app.yaml b/agent-pdf/.do/app.yaml index a0ee8f5..e8ab1c0 100644 --- a/agent-pdf/.do/app.yaml +++ b/agent-pdf/.do/app.yaml @@ -14,22 +14,22 @@ services: health_check: http_path: /health envs: - - key: SPACES_KEY + - key: S3_KEY scope: RUN_TIME type: SECRET - - key: SPACES_SECRET + - key: S3_SECRET scope: RUN_TIME type: SECRET - - key: SPACES_BUCKET - scope: RUN_TIME - value: agent-pdf - - key: SPACES_ENDPOINT + - key: S3_ENDPOINT scope: RUN_TIME value: https://nyc3.digitaloceanspaces.com - - key: SPACES_REGION + - key: S3_BUCKET + scope: RUN_TIME + value: agent-pdf + - key: S3_REGION scope: RUN_TIME value: nyc3 - - key: SPACES_PUBLIC_URL + - key: S3_PUBLIC_URL scope: RUN_TIME value: https://agent-pdf.nyc3.cdn.digitaloceanspaces.com - key: DEFAULT_EDITOR_HOST diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index b7f20f0..c171b24 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -1,8 +1,8 @@ pub struct Config { - pub bucket: String, - pub spaces_endpoint: String, - pub spaces_region: String, - pub public_url_prefix: String, + pub s3_endpoint: String, + pub s3_bucket: String, + pub s3_region: String, + pub s3_public_url: String, pub default_editor_host: String, pub rate_limit_per_minute: u32, pub trust_proxy: bool, @@ -10,11 +10,14 @@ pub struct Config { impl Config { pub fn from_env() -> Self { + let s3_endpoint = env("S3_ENDPOINT"); + let s3_bucket = env("S3_BUCKET"); + Self { - bucket: env("SPACES_BUCKET"), - spaces_endpoint: env("SPACES_ENDPOINT"), - spaces_region: env_or("SPACES_REGION", "nyc3"), - public_url_prefix: env("SPACES_PUBLIC_URL"), + s3_region: env_or("S3_REGION", "us-east-1"), + s3_public_url: env_or("S3_PUBLIC_URL", &s3_endpoint), + s3_endpoint, + s3_bucket, default_editor_host: env_or("DEFAULT_EDITOR_HOST", "ai.simplepdf.com"), rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") .parse() diff --git a/agent-pdf/src/storage.rs b/agent-pdf/src/storage.rs index 868990d..c27709e 100644 --- a/agent-pdf/src/storage.rs +++ b/agent-pdf/src/storage.rs @@ -8,7 +8,7 @@ use crate::error::AppError; pub struct Storage { client: Client, bucket: String, - public_url_prefix: String, + public_url: String, } pub struct UploadResult { @@ -18,32 +18,29 @@ pub struct UploadResult { impl Storage { pub async fn new(config: &Config) -> Self { let creds = aws_sdk_s3::config::Credentials::new( - std::env::var("SPACES_KEY").expect("SPACES_KEY must be set"), - std::env::var("SPACES_SECRET").expect("SPACES_SECRET must be set"), + std::env::var("S3_KEY").expect("S3_KEY must be set"), + std::env::var("S3_SECRET").expect("S3_SECRET must be set"), None, None, "env", ); let s3_config = aws_sdk_s3::Config::builder() - .endpoint_url(&config.spaces_endpoint) - .region(aws_sdk_s3::config::Region::new( - config.spaces_region.clone(), - )) + .behavior_version_latest() + .endpoint_url(&config.s3_endpoint) + .region(aws_sdk_s3::config::Region::new(config.s3_region.clone())) .credentials_provider(creds) .force_path_style(false) .build(); Self { client: Client::from_conf(s3_config), - bucket: config.bucket.clone(), - public_url_prefix: config.public_url_prefix.clone(), + bucket: config.s3_bucket.clone(), + public_url: config.s3_public_url.clone(), } } - /// Upload raw PDF bytes. Returns the file ID and public URL. pub async fn upload(&self, bytes: Vec) -> Result { - // Basic PDF validation: check magic bytes if bytes.len() < 5 || &bytes[..5] != b"%PDF-" { return Err(AppError::BadRequest("Not a valid PDF file".into())); } @@ -63,7 +60,7 @@ impl Storage { .await .map_err(|e| AppError::StorageFailed(e.to_string()))?; - let public_url = format!("{}/{key}", self.public_url_prefix); + let public_url = format!("{}/{key}", self.public_url); Ok(UploadResult { public_url }) } From 2c6df2e290935494c247c4fef5e40a0747ad8a8f Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:46:15 +0100 Subject: [PATCH 24/53] docs: rewrite README for quick start and deploy reference --- agent-pdf/README.md | 69 ++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 486d2d1..1fb7d21 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -1,57 +1,48 @@ # agent-pdf -Lightweight Rust backend for SimplePDF's agentic PDF editing API. Accepts a PDF (URL or binary), returns ready-to-embed editor URLs. +Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). -Hosted at `agent.simplepdf.com`. `GET /` without params serves the SKILL.md. `GET /?url=...` returns editor links. `POST /` handles file uploads. +`GET /` serves a [SKILL.md](./SKILL.md) that any AI agent can read to learn how to use this API. -## Endpoints - -### `GET /` - -Without `url` param: returns `SKILL.md` as `text/markdown` for agent discovery. - -With `url` param: returns JSON with editor embed codes. +## Quick start ```bash -# URL passthrough (no upload needed) +# From a URL curl "https://agent.simplepdf.com?url=https://example.com/form.pdf" -# With company-specific portal -curl "https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme" -``` - -### `POST /` - -File upload via multipart. Stored in DO Spaces (expires after 1hr). - -```bash +# From a file curl -X POST https://agent.simplepdf.com -F file=@document.pdf - -# With company-specific portal -curl -X POST "https://agent.simplepdf.com?companyIdentifier=acme" -F file=@document.pdf ``` -### Query parameters +Returns: -| Parameter | Required | Description | -|-----------|----------|-------------| -| `url` | GET only | PDF URL to open in the editor | -| `companyIdentifier` | No | Routes to `.simplepdf.com` instead of `embed.simplepdf.com` | +```json +{ + "url": "https://ai.simplepdf.com/editor?open=...", + "iframe": "", + "react": "" +} +``` ## Deploy -1. Create a DO Spaces bucket named `agent-pdf` with a lifecycle rule (1hr expiry) -2. Set the bucket ACL to public-read -3. Push to GitHub and deploy via App Platform (uses `.do/app.yaml`) -4. Set `SPACES_KEY` and `SPACES_SECRET` in the App Platform console +Runs on any container platform. See [.do/app.yaml](.do/app.yaml) for a DigitalOcean App Platform reference config. -## Architecture +Required env vars: -``` -Agent → GET /?url=PDF_URL → JSON response (passthrough) -Agent → POST / (file) → Rust (upload to Spaces) → JSON response - ↓ -User clicks URL → .simplepdf.com/editor?open=PDF_URL → client-side editing -``` +| Variable | Description | +|----------|-------------| +| `S3_ENDPOINT` | S3-compatible endpoint URL | +| `S3_BUCKET` | Bucket name | +| `S3_KEY` | Access key (secret) | +| `S3_SECRET` | Secret key (secret) | + +Optional: -No database. No auth. No sessions. Bucket lifecycle handles cleanup. +| Variable | Default | Description | +|----------|---------|-------------| +| `S3_REGION` | `us-east-1` | Bucket region | +| `S3_PUBLIC_URL` | Same as `S3_ENDPOINT` | CDN or public URL prefix for uploaded files | +| `DEFAULT_EDITOR_HOST` | `ai.simplepdf.com` | Editor host when no `companyIdentifier` is set | +| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For` for rate limiting | +| `RATE_LIMIT_PER_MIN` | `30` | Requests per IP per minute | From 5657cef7164b6665f182ad9953e4c56d965803bf Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:48:12 +0100 Subject: [PATCH 25/53] docs: simplify README to skill install + quick start --- agent-pdf/README.md | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 1fb7d21..6f5e0ca 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -2,7 +2,25 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). -`GET /` serves a [SKILL.md](./SKILL.md) that any AI agent can read to learn how to use this API. +## Install as a skill + +Copy the [SKILL.md](./SKILL.md) file into your agent's skills directory: + +```bash +# Claude Code +cp SKILL.md ~/.claude/skills/simplepdf/SKILL.md + +# Cursor +cp SKILL.md .cursor/skills/simplepdf/SKILL.md +``` + +Or point your agent at the hosted version: + +``` +https://agent.simplepdf.com +``` + +Any agent that fetches this URL gets the skill as `text/markdown`. ## Quick start @@ -23,26 +41,3 @@ Returns: "react": "" } ``` - -## Deploy - -Runs on any container platform. See [.do/app.yaml](.do/app.yaml) for a DigitalOcean App Platform reference config. - -Required env vars: - -| Variable | Description | -|----------|-------------| -| `S3_ENDPOINT` | S3-compatible endpoint URL | -| `S3_BUCKET` | Bucket name | -| `S3_KEY` | Access key (secret) | -| `S3_SECRET` | Secret key (secret) | - -Optional: - -| Variable | Default | Description | -|----------|---------|-------------| -| `S3_REGION` | `us-east-1` | Bucket region | -| `S3_PUBLIC_URL` | Same as `S3_ENDPOINT` | CDN or public URL prefix for uploaded files | -| `DEFAULT_EDITOR_HOST` | `ai.simplepdf.com` | Editor host when no `companyIdentifier` is set | -| `TRUST_PROXY` | `false` | Trust `X-Forwarded-For` for rate limiting | -| `RATE_LIMIT_PER_MIN` | `30` | Requests per IP per minute | From e74c123f50e8cacb77002f68b677218d9e156eef Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:53:27 +0100 Subject: [PATCH 26/53] fix: make all env vars required, no defaults --- agent-pdf/src/config.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/agent-pdf/src/config.rs b/agent-pdf/src/config.rs index c171b24..f4c31c5 100644 --- a/agent-pdf/src/config.rs +++ b/agent-pdf/src/config.rs @@ -10,19 +10,16 @@ pub struct Config { impl Config { pub fn from_env() -> Self { - let s3_endpoint = env("S3_ENDPOINT"); - let s3_bucket = env("S3_BUCKET"); - Self { - s3_region: env_or("S3_REGION", "us-east-1"), - s3_public_url: env_or("S3_PUBLIC_URL", &s3_endpoint), - s3_endpoint, - s3_bucket, - default_editor_host: env_or("DEFAULT_EDITOR_HOST", "ai.simplepdf.com"), - rate_limit_per_minute: env_or("RATE_LIMIT_PER_MIN", "30") + s3_endpoint: env("S3_ENDPOINT"), + s3_bucket: env("S3_BUCKET"), + s3_region: env("S3_REGION"), + s3_public_url: env("S3_PUBLIC_URL"), + default_editor_host: env("DEFAULT_EDITOR_HOST"), + rate_limit_per_minute: env("RATE_LIMIT_PER_MIN") .parse() .expect("RATE_LIMIT_PER_MIN must be a number"), - trust_proxy: env_or("TRUST_PROXY", "false") == "true", + trust_proxy: env("TRUST_PROXY") == "true", } } @@ -37,7 +34,3 @@ impl Config { fn env(key: &str) -> String { std::env::var(key).unwrap_or_else(|_| panic!("{key} must be set")) } - -fn env_or(key: &str, default: &str) -> String { - std::env::var(key).unwrap_or_else(|_| default.to_string()) -} From b6320d08156c6ad980fe2d79f6951fcd6c6b85d2 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 15:54:59 +0100 Subject: [PATCH 27/53] docs: simplify install to one-liner fetch --- agent-pdf/README.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 6f5e0ca..e5ba536 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -2,25 +2,15 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). -## Install as a skill +## Install -Copy the [SKILL.md](./SKILL.md) file into your agent's skills directory: - -```bash -# Claude Code -cp SKILL.md ~/.claude/skills/simplepdf/SKILL.md - -# Cursor -cp SKILL.md .cursor/skills/simplepdf/SKILL.md -``` - -Or point your agent at the hosted version: +Point your agent at: ``` https://agent.simplepdf.com ``` -Any agent that fetches this URL gets the skill as `text/markdown`. +Returns the skill as `text/markdown`. Save it to your agent's skills directory. ## Quick start From 3fff912a3b52fbe7538d678c9cbd38fe4875c815 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 16:00:38 +0100 Subject: [PATCH 28/53] docs: rename skill to edit-pdf, one-liner install --- agent-pdf/README.md | 8 ++------ agent-pdf/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index e5ba536..7617fc0 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -4,14 +4,10 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). ## Install -Point your agent at: - -``` -https://agent.simplepdf.com +```bash +mkdir -p ~/.claude/skills/edit-pdf && curl -o ~/.claude/skills/edit-pdf/SKILL.md https://agent.simplepdf.com ``` -Returns the skill as `text/markdown`. Save it to your agent's skills directory. - ## Quick start ```bash diff --git a/agent-pdf/SKILL.md b/agent-pdf/SKILL.md index e47e27a..9781c77 100644 --- a/agent-pdf/SKILL.md +++ b/agent-pdf/SKILL.md @@ -1,5 +1,5 @@ --- -name: simplepdf +name: edit-pdf description: Edit and fill PDF documents. Use when the user wants to fill a PDF form, add text/signatures/checkboxes/images to a PDF, or annotate a PDF. Accepts a PDF URL or file upload and returns a ready-to-use editor link. URL inputs are passed From 2ded4af703e7c88aa43f3a02506a4c0259937d88 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 16:48:26 +0100 Subject: [PATCH 29/53] docs: use curl --create-dirs for cleaner install one-liner --- agent-pdf/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-pdf/README.md b/agent-pdf/README.md index 7617fc0..5a61c3b 100644 --- a/agent-pdf/README.md +++ b/agent-pdf/README.md @@ -5,7 +5,7 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). ## Install ```bash -mkdir -p ~/.claude/skills/edit-pdf && curl -o ~/.claude/skills/edit-pdf/SKILL.md https://agent.simplepdf.com +curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agent.simplepdf.com ``` ## Quick start From 8c6e3beb956983b95d1cbe44bb394b71298e94da Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:02:16 +0100 Subject: [PATCH 30/53] chore: rename agent-pdf/ to agents/ for discoverability --- .../workflows/{agent-pdf.yaml => agents.yaml} | 16 ++++++++-------- {agent-pdf => agents}/.do/app.yaml | 2 +- {agent-pdf => agents}/.gitignore | 0 {agent-pdf => agents}/Cargo.lock | 2 +- {agent-pdf => agents}/Cargo.toml | 2 +- {agent-pdf => agents}/Dockerfile | 4 ++-- {agent-pdf => agents}/README.md | 0 {agent-pdf => agents}/SKILL.md | 0 {agent-pdf => agents}/src/config.rs | 0 {agent-pdf => agents}/src/error.rs | 0 {agent-pdf => agents}/src/main.rs | 0 {agent-pdf => agents}/src/rate_limit.rs | 0 {agent-pdf => agents}/src/routes.rs | 0 {agent-pdf => agents}/src/storage.rs | 0 14 files changed, 13 insertions(+), 13 deletions(-) rename .github/workflows/{agent-pdf.yaml => agents.yaml} (71%) rename {agent-pdf => agents}/.do/app.yaml (97%) rename {agent-pdf => agents}/.gitignore (100%) rename {agent-pdf => agents}/Cargo.lock (99%) rename {agent-pdf => agents}/Cargo.toml (96%) rename {agent-pdf => agents}/Dockerfile (73%) rename {agent-pdf => agents}/README.md (100%) rename {agent-pdf => agents}/SKILL.md (100%) rename {agent-pdf => agents}/src/config.rs (100%) rename {agent-pdf => agents}/src/error.rs (100%) rename {agent-pdf => agents}/src/main.rs (100%) rename {agent-pdf => agents}/src/rate_limit.rs (100%) rename {agent-pdf => agents}/src/routes.rs (100%) rename {agent-pdf => agents}/src/storage.rs (100%) diff --git a/.github/workflows/agent-pdf.yaml b/.github/workflows/agents.yaml similarity index 71% rename from .github/workflows/agent-pdf.yaml rename to .github/workflows/agents.yaml index 69cb744..6fd0c74 100644 --- a/.github/workflows/agent-pdf.yaml +++ b/.github/workflows/agents.yaml @@ -1,25 +1,25 @@ -name: Agent PDF +name: Agents on: push: branches: - main paths: - - agent-pdf/** - - .github/workflows/agent-pdf.yaml + - agents/** + - .github/workflows/agents.yaml pull_request: branches: - main paths: - - agent-pdf/** - - .github/workflows/agent-pdf.yaml + - agents/** + - .github/workflows/agents.yaml jobs: check: runs-on: ubuntu-latest defaults: run: - working-directory: agent-pdf + working-directory: agents steps: - name: Checkout code @@ -36,8 +36,8 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - agent-pdf/target - key: ${{ runner.os }}-cargo-${{ hashFiles('agent-pdf/Cargo.lock') }} + agents/target + key: ${{ runner.os }}-cargo-${{ hashFiles('agents/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - name: Format diff --git a/agent-pdf/.do/app.yaml b/agents/.do/app.yaml similarity index 97% rename from agent-pdf/.do/app.yaml rename to agents/.do/app.yaml index e8ab1c0..0f75ced 100644 --- a/agent-pdf/.do/app.yaml +++ b/agents/.do/app.yaml @@ -6,7 +6,7 @@ services: repo: SimplePDF/simplepdf-embed branch: main deploy_on_push: true - source_dir: agent-pdf + source_dir: agents dockerfile_path: Dockerfile instance_count: 1 instance_size_slug: basic-xs diff --git a/agent-pdf/.gitignore b/agents/.gitignore similarity index 100% rename from agent-pdf/.gitignore rename to agents/.gitignore diff --git a/agent-pdf/Cargo.lock b/agents/Cargo.lock similarity index 99% rename from agent-pdf/Cargo.lock rename to agents/Cargo.lock index 6d5acce..0d01cd1 100644 --- a/agent-pdf/Cargo.lock +++ b/agents/Cargo.lock @@ -3,7 +3,7 @@ version = 4 [[package]] -name = "agent-pdf" +name = "agents" version = "0.1.0" dependencies = [ "aws-sdk-s3", diff --git a/agent-pdf/Cargo.toml b/agents/Cargo.toml similarity index 96% rename from agent-pdf/Cargo.toml rename to agents/Cargo.toml index c2c5484..679bcbf 100644 --- a/agent-pdf/Cargo.toml +++ b/agents/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "agent-pdf" +name = "agents" version = "0.1.0" edition = "2021" diff --git a/agent-pdf/Dockerfile b/agents/Dockerfile similarity index 73% rename from agent-pdf/Dockerfile rename to agents/Dockerfile index e122ed1..51b6046 100644 --- a/agent-pdf/Dockerfile +++ b/agents/Dockerfile @@ -7,6 +7,6 @@ RUN cargo build --release 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/agent-pdf /usr/local/bin/agent-pdf +COPY --from=builder /app/target/release/agents /usr/local/bin/agents EXPOSE 8080 -CMD ["agent-pdf"] +CMD ["agents"] diff --git a/agent-pdf/README.md b/agents/README.md similarity index 100% rename from agent-pdf/README.md rename to agents/README.md diff --git a/agent-pdf/SKILL.md b/agents/SKILL.md similarity index 100% rename from agent-pdf/SKILL.md rename to agents/SKILL.md diff --git a/agent-pdf/src/config.rs b/agents/src/config.rs similarity index 100% rename from agent-pdf/src/config.rs rename to agents/src/config.rs diff --git a/agent-pdf/src/error.rs b/agents/src/error.rs similarity index 100% rename from agent-pdf/src/error.rs rename to agents/src/error.rs diff --git a/agent-pdf/src/main.rs b/agents/src/main.rs similarity index 100% rename from agent-pdf/src/main.rs rename to agents/src/main.rs diff --git a/agent-pdf/src/rate_limit.rs b/agents/src/rate_limit.rs similarity index 100% rename from agent-pdf/src/rate_limit.rs rename to agents/src/rate_limit.rs diff --git a/agent-pdf/src/routes.rs b/agents/src/routes.rs similarity index 100% rename from agent-pdf/src/routes.rs rename to agents/src/routes.rs diff --git a/agent-pdf/src/storage.rs b/agents/src/storage.rs similarity index 100% rename from agent-pdf/src/storage.rs rename to agents/src/storage.rs From 80a9ea70e744c5541d827431eaa7625a66405bc9 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:07:30 +0100 Subject: [PATCH 31/53] docs: bump file expiry to 24h, clarify tab stays open indefinitely --- agents/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/SKILL.md b/agents/SKILL.md index 9781c77..e4a3b9e 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -3,7 +3,7 @@ name: edit-pdf description: Edit and fill PDF documents. Use when the user wants to fill a PDF form, add text/signatures/checkboxes/images to a PDF, or annotate a PDF. Accepts a PDF URL or file upload and returns a ready-to-use editor link. URL inputs are passed - through directly. File uploads are stored temporarily (1 hour) then deleted. + through directly. File uploads are stored temporarily (24 hours) then deleted. --- # SimplePDF - PDF Editor @@ -30,7 +30,7 @@ Both return JSON with the editor URL and embed snippets. ## From a file -Upload a PDF file to the API. The file is stored temporarily (1 hour) to make it accessible to the browser-based editor. +Upload a PDF file to the API. For security and privacy, the file is stored temporarily (24 hours) then automatically deleted. The user has 24 hours to open the link. Once opened, the PDF is loaded into the browser and processed entirely client-side, so the tab can stay open indefinitely. ### Shell @@ -133,7 +133,7 @@ With a company-specific portal, replace `"ai"` with the portal identifier: ## Privacy - **URL input**: The PDF URL is passed directly to the browser-based editor. The PDF is never downloaded or stored by this service. -- **File upload**: The PDF is temporarily stored for up to 1 hour to make it accessible to the browser-based editor, then automatically deleted. +- **File upload**: For security and privacy, uploaded PDFs are stored for up to 24 hours then automatically deleted. Once the editor loads the PDF in the browser, the tab works independently of the stored file. - **Editing**: All PDF editing happens client-side in the browser. The edited document is never sent to SimplePDF servers. ## Supported operations @@ -150,7 +150,7 @@ Once the user opens the editor link, they can: ## Limits - Maximum PDF size: 50 MB (file uploads only) -- Uploaded files expire after 1 hour +- Uploaded files expire after 24 hours - Rate limit: 30 requests per minute per IP - URL must start with http:// or https:// - companyIdentifier must be a valid SimplePDF portal identifier From ec73cb18b5da258e4068fae9a0095f055be87e3c Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:08:21 +0100 Subject: [PATCH 32/53] docs: add open source mention with GitHub link --- agents/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agents/SKILL.md b/agents/SKILL.md index e4a3b9e..2158e62 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -155,6 +155,10 @@ Once the user opens the editor link, they can: - URL must start with http:// or https:// - companyIdentifier must be a valid SimplePDF portal identifier +## Open source + +The code powering `agent.simplepdf.com` is open source: [github.com/SimplePDF/simplepdf-embed/tree/main/agents](https://github.com/SimplePDF/simplepdf-embed/tree/main/agents) + ## Legal SimplePDF is not responsible for the content of uploaded PDFs. By using this service, you agree that you have the right to process the documents you upload. For concerns or requests, contact support@simplepdf.com. From bd2cc3753475767cc47b0029a2e67331185701aa Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:10:20 +0100 Subject: [PATCH 33/53] docs: rename domain to agents.simplepdf.com for consistency with folder name --- agents/README.md | 6 +++--- agents/SKILL.md | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/agents/README.md b/agents/README.md index 5a61c3b..f30fa3c 100644 --- a/agents/README.md +++ b/agents/README.md @@ -5,17 +5,17 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). ## Install ```bash -curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agent.simplepdf.com +curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agents.simplepdf.com ``` ## Quick start ```bash # From a URL -curl "https://agent.simplepdf.com?url=https://example.com/form.pdf" +curl "https://agents.simplepdf.com?url=https://example.com/form.pdf" # From a file -curl -X POST https://agent.simplepdf.com -F file=@document.pdf +curl -X POST https://agents.simplepdf.com -F file=@document.pdf ``` Returns: diff --git a/agents/SKILL.md b/agents/SKILL.md index 2158e62..3a7a8a7 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -15,13 +15,13 @@ Edit and fill PDF documents directly in the browser. Add text, signatures, check For public PDF URLs, use GET with the URL as a query parameter: ``` -GET https://agent.simplepdf.com?url=https://example.com/form.pdf +GET https://agents.simplepdf.com?url=https://example.com/form.pdf ``` For signed or sensitive URLs (e.g. presigned S3 links), use POST with a JSON body to keep the URL out of logs and browser history: ```bash -curl -X POST https://agent.simplepdf.com \ +curl -X POST https://agents.simplepdf.com \ -H "Content-Type: application/json" \ -d '{"url": "https://s3.amazonaws.com/bucket/doc.pdf?X-Amz-Signature=..."}' ``` @@ -35,7 +35,7 @@ Upload a PDF file to the API. For security and privacy, the file is stored tempo ### Shell ```bash -curl -X POST https://agent.simplepdf.com -F file=@document.pdf +curl -X POST https://agents.simplepdf.com -F file=@document.pdf ``` ### TypeScript @@ -44,7 +44,7 @@ curl -X POST https://agent.simplepdf.com -F file=@document.pdf const form = new FormData(); form.append("file", new Blob([pdfBytes], { type: "application/pdf" }), "document.pdf"); -const response = await fetch("https://agent.simplepdf.com", { method: "POST", body: form }); +const response = await fetch("https://agents.simplepdf.com", { method: "POST", body: form }); const { url } = await response.json(); ``` @@ -54,7 +54,7 @@ const { url } = await response.json(); import requests with open("document.pdf", "rb") as f: - response = requests.post("https://agent.simplepdf.com", files={"file": f}) + response = requests.post("https://agents.simplepdf.com", files={"file": f}) url = response.json()["url"] ``` @@ -72,7 +72,7 @@ The `iframe` and `react` fields are for developers embedding the editor in a web Add `companyIdentifier` to route to a custom SimplePDF portal: ``` -GET https://agent.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme +GET https://agents.simplepdf.com?url=https://example.com/form.pdf&companyIdentifier=acme ``` Or in a POST JSON body: @@ -157,7 +157,7 @@ Once the user opens the editor link, they can: ## Open source -The code powering `agent.simplepdf.com` is open source: [github.com/SimplePDF/simplepdf-embed/tree/main/agents](https://github.com/SimplePDF/simplepdf-embed/tree/main/agents) +The code powering `agents.simplepdf.com` is open source: [github.com/SimplePDF/simplepdf-embed/tree/main/agents](https://github.com/SimplePDF/simplepdf-embed/tree/main/agents) ## Legal From 0c8339c30ab7ca12f2b16929fad5cfd0ffafb8af Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:13:24 +0100 Subject: [PATCH 34/53] feat: serve README at root, SKILL.md at /SKILL.md --- agents/README.md | 2 +- agents/src/routes.rs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/agents/README.md b/agents/README.md index f30fa3c..e046531 100644 --- a/agents/README.md +++ b/agents/README.md @@ -5,7 +5,7 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). ## Install ```bash -curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agents.simplepdf.com +curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agents.simplepdf.com/SKILL.md ``` ## Quick start diff --git a/agents/src/routes.rs b/agents/src/routes.rs index d5a3219..8aa453d 100644 --- a/agents/src/routes.rs +++ b/agents/src/routes.rs @@ -10,14 +10,23 @@ use std::sync::Arc; use crate::error::AppError; use crate::AppState; +const README_MD: &str = include_str!("../README.md"); const SKILL_MD: &str = include_str!("../SKILL.md"); pub fn router() -> Router> { Router::new() .route("/", get(handle_get).post(handle_post)) + .route("/SKILL.md", get(serve_skill)) .route("/health", get(|| async { "ok" })) } +async fn serve_skill() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], + SKILL_MD, + ) +} + #[derive(Deserialize)] struct GetQuery { url: Option, @@ -73,7 +82,7 @@ async fn handle_get( None => { return Ok(( [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], - SKILL_MD, + README_MD, ) .into_response()); } From 374644516d36b70c9a508a35cde899e37547a6e2 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:16:00 +0100 Subject: [PATCH 35/53] docs: move source code link to README, remove from SKILL.md --- agents/README.md | 4 ++++ agents/SKILL.md | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/README.md b/agents/README.md index e046531..86545f5 100644 --- a/agents/README.md +++ b/agents/README.md @@ -27,3 +27,7 @@ Returns: "react": "" } ``` + +## Source code + +[github.com/SimplePDF/simplepdf-embed/tree/main/agents](https://github.com/SimplePDF/simplepdf-embed/tree/main/agents) diff --git a/agents/SKILL.md b/agents/SKILL.md index 3a7a8a7..c27ba41 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -155,10 +155,6 @@ Once the user opens the editor link, they can: - URL must start with http:// or https:// - companyIdentifier must be a valid SimplePDF portal identifier -## Open source - -The code powering `agents.simplepdf.com` is open source: [github.com/SimplePDF/simplepdf-embed/tree/main/agents](https://github.com/SimplePDF/simplepdf-embed/tree/main/agents) - ## Legal SimplePDF is not responsible for the content of uploaded PDFs. By using this service, you agree that you have the right to process the documents you upload. For concerns or requests, contact support@simplepdf.com. From df70cec3b4b68f7f63d02252d57f5a5ed77f3013 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:20:57 +0100 Subject: [PATCH 36/53] docs: rename Install to Add the edit-pdf skill --- agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/README.md b/agents/README.md index 86545f5..fadb63f 100644 --- a/agents/README.md +++ b/agents/README.md @@ -2,7 +2,7 @@ Let AI agents edit and fill PDFs through [SimplePDF](https://simplepdf.com). -## Install +## Add the "edit-pdf" skill ```bash curl --create-dirs -o ~/.claude/skills/edit-pdf/SKILL.md https://agents.simplepdf.com/SKILL.md From 9d433992c7003267fceffe6d58e69511b60f1ee3 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:21:48 +0100 Subject: [PATCH 37/53] docs: remove automatic form field detection from supported operations --- agents/SKILL.md | 1 - 1 file changed, 1 deletion(-) diff --git a/agents/SKILL.md b/agents/SKILL.md index c27ba41..ea82b5d 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -140,7 +140,6 @@ With a company-specific portal, replace `"ai"` with the portal identifier: Once the user opens the editor link, they can: -- Automatic detection of form fields - Fill form fields (text inputs, checkboxes, radio buttons, dropdowns) - Add free text annotations anywhere on the page - Draw or type signatures From 8bab5cdcc49886608dffb879de951fe40faec4c3 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:29:01 +0100 Subject: [PATCH 38/53] feat: switch to presigned URLs, remove public-read ACL - Upload without public-read ACL (private by default) - Generate 24h presigned GET URL after upload - Remove S3_PUBLIC_URL env var (no longer needed) --- agents/.do/app.yaml | 3 --- agents/src/config.rs | 2 -- agents/src/routes.rs | 2 +- agents/src/storage.rs | 25 +++++++++++++++++++------ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/agents/.do/app.yaml b/agents/.do/app.yaml index 0f75ced..c9eb5fe 100644 --- a/agents/.do/app.yaml +++ b/agents/.do/app.yaml @@ -29,9 +29,6 @@ services: - key: S3_REGION scope: RUN_TIME value: nyc3 - - key: S3_PUBLIC_URL - scope: RUN_TIME - value: https://agent-pdf.nyc3.cdn.digitaloceanspaces.com - key: DEFAULT_EDITOR_HOST scope: RUN_TIME value: ai.simplepdf.com diff --git a/agents/src/config.rs b/agents/src/config.rs index f4c31c5..7e47e86 100644 --- a/agents/src/config.rs +++ b/agents/src/config.rs @@ -2,7 +2,6 @@ pub struct Config { pub s3_endpoint: String, pub s3_bucket: String, pub s3_region: String, - pub s3_public_url: String, pub default_editor_host: String, pub rate_limit_per_minute: u32, pub trust_proxy: bool, @@ -14,7 +13,6 @@ impl Config { s3_endpoint: env("S3_ENDPOINT"), s3_bucket: env("S3_BUCKET"), s3_region: env("S3_REGION"), - s3_public_url: env("S3_PUBLIC_URL"), default_editor_host: env("DEFAULT_EDITOR_HOST"), rate_limit_per_minute: env("RATE_LIMIT_PER_MIN") .parse() diff --git a/agents/src/routes.rs b/agents/src/routes.rs index 8aa453d..66749a6 100644 --- a/agents/src/routes.rs +++ b/agents/src/routes.rs @@ -142,7 +142,7 @@ async fn handle_post( let result = state.storage.upload(pdf_bytes).await?; Ok(Json(AgentResponse::new( - &result.public_url, + &result.presigned_url, &editor_base, company_identifier, ))) diff --git a/agents/src/storage.rs b/agents/src/storage.rs index c27709e..b5ef336 100644 --- a/agents/src/storage.rs +++ b/agents/src/storage.rs @@ -1,18 +1,21 @@ +use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::Client; +use std::time::Duration; use uuid::Uuid; use crate::config::Config; use crate::error::AppError; +const PRESIGN_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60); + pub struct Storage { client: Client, bucket: String, - public_url: String, } pub struct UploadResult { - pub public_url: String, + pub presigned_url: String, } impl Storage { @@ -36,7 +39,6 @@ impl Storage { Self { client: Client::from_conf(s3_config), bucket: config.s3_bucket.clone(), - public_url: config.s3_public_url.clone(), } } @@ -55,13 +57,24 @@ impl Storage { .body(ByteStream::from(bytes)) .content_type("application/pdf") .content_disposition("attachment") - .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead) .send() .await .map_err(|e| AppError::StorageFailed(e.to_string()))?; - let public_url = format!("{}/{key}", self.public_url); + let presign_config = PresigningConfig::expires_in(PRESIGN_EXPIRY) + .map_err(|e| AppError::StorageFailed(e.to_string()))?; + + let presigned_url = self + .client + .get_object() + .bucket(&self.bucket) + .key(&key) + .presigned(presign_config) + .await + .map_err(|e| AppError::StorageFailed(e.to_string()))? + .uri() + .to_string(); - Ok(UploadResult { public_url }) + Ok(UploadResult { presigned_url }) } } From 30387843aa0ce936d2bff67ce95d59b59b90fc2c Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:37:58 +0100 Subject: [PATCH 39/53] feat: preserve original filename with short hash suffix on upload --- agents/src/routes.rs | 20 +++++++++--- agents/src/storage.rs | 73 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/agents/src/routes.rs b/agents/src/routes.rs index 66749a6..89a3068 100644 --- a/agents/src/routes.rs +++ b/agents/src/routes.rs @@ -138,8 +138,11 @@ async fn handle_post( .map_err(|_| { AppError::BadRequest("Expected multipart/form-data with a 'file' field".into()) })?; - let pdf_bytes = extract_multipart(multipart).await?; - let result = state.storage.upload(pdf_bytes).await?; + let upload = extract_multipart(multipart).await?; + let result = state + .storage + .upload(upload.bytes, upload.filename.as_deref()) + .await?; Ok(Json(AgentResponse::new( &result.presigned_url, @@ -179,18 +182,27 @@ async fn handle_post( } } -async fn extract_multipart(mut multipart: Multipart) -> Result, AppError> { +struct MultipartUpload { + bytes: Vec, + filename: Option, +} + +async fn extract_multipart(mut multipart: Multipart) -> Result { while let Some(field) = multipart .next_field() .await .map_err(|_| AppError::BadRequest("Failed to read multipart field".into()))? { if field.name() == Some("file") { + let filename = field.file_name().map(String::from); let bytes = field .bytes() .await .map_err(|_| AppError::BadRequest("Failed to read file".into()))?; - return Ok(bytes.to_vec()); + return Ok(MultipartUpload { + bytes: bytes.to_vec(), + filename, + }); } } diff --git a/agents/src/storage.rs b/agents/src/storage.rs index b5ef336..a00c59e 100644 --- a/agents/src/storage.rs +++ b/agents/src/storage.rs @@ -42,13 +42,17 @@ impl Storage { } } - pub async fn upload(&self, bytes: Vec) -> Result { + pub async fn upload( + &self, + bytes: Vec, + original_filename: Option<&str>, + ) -> Result { if bytes.len() < 5 || &bytes[..5] != b"%PDF-" { return Err(AppError::BadRequest("Not a valid PDF file".into())); } - let id = Uuid::new_v4().to_string(); - let key = format!("uploads/{id}.pdf"); + let hash = &Uuid::new_v4().to_string()[..8]; + let key = build_key(original_filename, hash); self.client .put_object() @@ -78,3 +82,66 @@ impl Storage { Ok(UploadResult { presigned_url }) } } + +fn build_key(original_filename: Option<&str>, hash: &str) -> String { + let stem_and_ext = original_filename + .filter(|name| !name.is_empty()) + .map(|name| { + let name = name + .rsplit('/') + .next() + .unwrap_or(name) + .rsplit('\\') + .next() + .unwrap_or(name); + + match name.rsplit_once('.') { + Some((stem, ext)) => (stem.to_string(), format!(".{ext}")), + None => (name.to_string(), String::new()), + } + }); + + match stem_and_ext { + Some((stem, ext)) => format!("uploads/{stem}-{hash}{ext}"), + None => format!("uploads/{hash}.pdf"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_key_with_filename() { + assert_eq!( + build_key(Some("invoice.pdf"), "a1b2c3d4"), + "uploads/invoice-a1b2c3d4.pdf" + ); + } + + #[test] + fn test_build_key_with_path() { + assert_eq!( + build_key(Some("path/to/report.pdf"), "a1b2c3d4"), + "uploads/report-a1b2c3d4.pdf" + ); + } + + #[test] + fn test_build_key_without_extension() { + assert_eq!( + build_key(Some("document"), "a1b2c3d4"), + "uploads/document-a1b2c3d4" + ); + } + + #[test] + fn test_build_key_none() { + assert_eq!(build_key(None, "a1b2c3d4"), "uploads/a1b2c3d4.pdf"); + } + + #[test] + fn test_build_key_empty() { + assert_eq!(build_key(Some(""), "a1b2c3d4"), "uploads/a1b2c3d4.pdf"); + } +} From 14544651b5d45aa096f4b19ae2828c509754dad8 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:44:26 +0100 Subject: [PATCH 40/53] feat: add hourly background cleanup of expired uploads (>24h) --- agents/src/main.rs | 12 ++++++++ agents/src/storage.rs | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/agents/src/main.rs b/agents/src/main.rs index da036f1..0006e6b 100644 --- a/agents/src/main.rs +++ b/agents/src/main.rs @@ -30,6 +30,18 @@ async fn main() { config, }); + tokio::spawn({ + let state = Arc::clone(&state); + async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); + interval.tick().await; + loop { + interval.tick().await; + state.storage.cleanup_expired().await; + } + } + }); + let app = Router::new() .merge(routes::router()) .layer(CorsLayer::permissive()) diff --git a/agents/src/storage.rs b/agents/src/storage.rs index a00c59e..b4b156b 100644 --- a/agents/src/storage.rs +++ b/agents/src/storage.rs @@ -8,6 +8,7 @@ use crate::config::Config; use crate::error::AppError; const PRESIGN_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60); +const CLEANUP_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); pub struct Storage { client: Client, @@ -81,6 +82,76 @@ impl Storage { Ok(UploadResult { presigned_url }) } + + pub async fn cleanup_expired(&self) { + let cutoff = aws_sdk_s3::primitives::DateTime::from_secs_f64( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() + - CLEANUP_MAX_AGE.as_secs_f64(), + ); + + let mut continuation_token: Option = None; + + loop { + let mut request = self + .client + .list_objects_v2() + .bucket(&self.bucket) + .prefix("uploads/"); + + if let Some(token) = &continuation_token { + request = request.continuation_token(token); + } + + let response = match request.send().await { + Ok(response) => response, + Err(e) => { + tracing::error!("cleanup: failed to list objects: {e}"); + return; + } + }; + + let mut deleted = 0; + for object in response.contents() { + let is_expired = object + .last_modified() + .map(|modified| *modified < cutoff) + .unwrap_or(false); + + if !is_expired { + continue; + } + + let Some(key) = object.key() else { + continue; + }; + + if let Err(e) = self + .client + .delete_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + { + tracing::error!("cleanup: failed to delete {key}: {e}"); + } else { + deleted += 1; + } + } + + if deleted > 0 { + tracing::info!("cleanup: deleted {deleted} expired files"); + } + + match response.next_continuation_token() { + Some(token) => continuation_token = Some(token.to_string()), + None => break, + } + } + } } fn build_key(original_filename: Option<&str>, hash: &str) -> String { From 5c1fd97c3cd59f1359bebe204ae87ca8abddfbc0 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 17:48:13 +0100 Subject: [PATCH 41/53] feat: replace hourly cleanup cron with S3 lifecycle policy set at startup --- agents/src/main.rs | 12 +---- agents/src/storage.rs | 104 +++++++++++++++--------------------------- 2 files changed, 37 insertions(+), 79 deletions(-) diff --git a/agents/src/main.rs b/agents/src/main.rs index 0006e6b..4f74384 100644 --- a/agents/src/main.rs +++ b/agents/src/main.rs @@ -30,17 +30,7 @@ async fn main() { config, }); - tokio::spawn({ - let state = Arc::clone(&state); - async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); - interval.tick().await; - loop { - interval.tick().await; - state.storage.cleanup_expired().await; - } - } - }); + state.storage.ensure_lifecycle_policy().await; let app = Router::new() .merge(routes::router()) diff --git a/agents/src/storage.rs b/agents/src/storage.rs index b4b156b..20899a7 100644 --- a/agents/src/storage.rs +++ b/agents/src/storage.rs @@ -8,7 +8,7 @@ use crate::config::Config; use crate::error::AppError; const PRESIGN_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60); -const CLEANUP_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); +const LIFECYCLE_EXPIRY_DAYS: i32 = 1; pub struct Storage { client: Client, @@ -83,73 +83,41 @@ impl Storage { Ok(UploadResult { presigned_url }) } - pub async fn cleanup_expired(&self) { - let cutoff = aws_sdk_s3::primitives::DateTime::from_secs_f64( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs_f64() - - CLEANUP_MAX_AGE.as_secs_f64(), - ); - - let mut continuation_token: Option = None; - - loop { - let mut request = self - .client - .list_objects_v2() - .bucket(&self.bucket) - .prefix("uploads/"); - - if let Some(token) = &continuation_token { - request = request.continuation_token(token); - } - - let response = match request.send().await { - Ok(response) => response, - Err(e) => { - tracing::error!("cleanup: failed to list objects: {e}"); - return; - } - }; - - let mut deleted = 0; - for object in response.contents() { - let is_expired = object - .last_modified() - .map(|modified| *modified < cutoff) - .unwrap_or(false); - - if !is_expired { - continue; - } - - let Some(key) = object.key() else { - continue; - }; - - if let Err(e) = self - .client - .delete_object() - .bucket(&self.bucket) - .key(key) - .send() - .await - { - tracing::error!("cleanup: failed to delete {key}: {e}"); - } else { - deleted += 1; - } - } - - if deleted > 0 { - tracing::info!("cleanup: deleted {deleted} expired files"); - } - - match response.next_continuation_token() { - Some(token) => continuation_token = Some(token.to_string()), - None => break, - } + pub async fn ensure_lifecycle_policy(&self) { + use aws_sdk_s3::types::{ + BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, + LifecycleRuleFilter, + }; + + let rule = LifecycleRule::builder() + .id("expire-uploads-24h") + .status(ExpirationStatus::Enabled) + .filter(LifecycleRuleFilter::builder().prefix("uploads/").build()) + .expiration( + LifecycleExpiration::builder() + .days(LIFECYCLE_EXPIRY_DAYS) + .build(), + ) + .build() + .expect("valid lifecycle rule"); + + let config = BucketLifecycleConfiguration::builder() + .rules(rule) + .build() + .expect("valid lifecycle config"); + + match self + .client + .put_bucket_lifecycle_configuration() + .bucket(&self.bucket) + .lifecycle_configuration(config) + .send() + .await + { + Ok(_) => tracing::info!( + "lifecycle policy set: uploads/ expire after {LIFECYCLE_EXPIRY_DAYS} day(s)" + ), + Err(e) => tracing::error!("failed to set lifecycle policy: {e}"), } } } From ca5e7da4996d3023bce3eb96e1e62c2babd7b578 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 Mar 2026 18:16:50 +0100 Subject: [PATCH 42/53] fix: configurable trusted IP header (do-connecting-ip), reject http:// URLs - Replace TRUST_PROXY bool with TRUSTED_IP_HEADER string (set to do-connecting-ip for DO App Platform, empty to disable) - Only accept https:// URLs (http:// causes mixed-content in the editor) --- agents/.do/app.yaml | 4 ++-- agents/SKILL.md | 2 +- agents/src/config.rs | 4 ++-- agents/src/routes.rs | 26 +++++++++++++------------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/agents/.do/app.yaml b/agents/.do/app.yaml index c9eb5fe..e3e1513 100644 --- a/agents/.do/app.yaml +++ b/agents/.do/app.yaml @@ -32,9 +32,9 @@ services: - key: DEFAULT_EDITOR_HOST scope: RUN_TIME value: ai.simplepdf.com - - key: TRUST_PROXY + - key: TRUSTED_IP_HEADER scope: RUN_TIME - value: "true" + value: do-connecting-ip - key: RATE_LIMIT_PER_MIN scope: RUN_TIME value: "30" diff --git a/agents/SKILL.md b/agents/SKILL.md index ea82b5d..0c799c7 100644 --- a/agents/SKILL.md +++ b/agents/SKILL.md @@ -151,7 +151,7 @@ Once the user opens the editor link, they can: - Maximum PDF size: 50 MB (file uploads only) - Uploaded files expire after 24 hours - Rate limit: 30 requests per minute per IP -- URL must start with http:// or https:// +- URL must start with https:// - companyIdentifier must be a valid SimplePDF portal identifier ## Legal diff --git a/agents/src/config.rs b/agents/src/config.rs index 7e47e86..12f9aa6 100644 --- a/agents/src/config.rs +++ b/agents/src/config.rs @@ -4,7 +4,7 @@ pub struct Config { pub s3_region: String, pub default_editor_host: String, pub rate_limit_per_minute: u32, - pub trust_proxy: bool, + pub trusted_ip_header: String, } impl Config { @@ -17,7 +17,7 @@ impl Config { rate_limit_per_minute: env("RATE_LIMIT_PER_MIN") .parse() .expect("RATE_LIMIT_PER_MIN must be a number"), - trust_proxy: env("TRUST_PROXY") == "true", + trusted_ip_header: env("TRUSTED_IP_HEADER"), } } diff --git a/agents/src/routes.rs b/agents/src/routes.rs index 89a3068..2a894f2 100644 --- a/agents/src/routes.rs +++ b/agents/src/routes.rs @@ -89,15 +89,13 @@ async fn handle_get( Some(url) => url, }; - let ip = client_ip(&request, addr.ip(), state.config.trust_proxy); + let ip = client_ip(&request, addr.ip(), &state.config.trusted_ip_header); if !state.rate_limiter.check(ip) { return Err(AppError::RateLimited); } if !is_valid_url(&pdf_url) { - return Err(AppError::BadRequest( - "url must start with http:// or https://".into(), - )); + return Err(AppError::BadRequest("url must start with https://".into())); } let company_identifier = validate_company_identifier(query.company_identifier.as_deref())?; @@ -117,7 +115,7 @@ async fn handle_post( Query(query): Query, request: axum::extract::Request, ) -> Result, AppError> { - let ip = client_ip(&request, addr.ip(), state.config.trust_proxy); + let ip = client_ip(&request, addr.ip(), &state.config.trusted_ip_header); if !state.rate_limiter.check(ip) { return Err(AppError::RateLimited); } @@ -161,9 +159,7 @@ async fn handle_post( })?; if !is_valid_url(&input.url) { - return Err(AppError::BadRequest( - "url must start with http:// or https://".into(), - )); + return Err(AppError::BadRequest("url must start with https://".into())); } let company_identifier = validate_company_identifier( @@ -211,16 +207,20 @@ async fn extract_multipart(mut multipart: Multipart) -> Result IpAddr { - if !trust_proxy { +fn client_ip( + request: &axum::extract::Request, + fallback: IpAddr, + trusted_ip_header: &str, +) -> IpAddr { + if trusted_ip_header.is_empty() { return fallback; } request .headers() - .get("x-forwarded-for") + .get(trusted_ip_header) .and_then(|v| v.to_str().ok()) - .and_then(|v| v.rsplit(',').next()) + .and_then(|v| v.split(',').next()) .and_then(|v| v.trim().parse::().ok()) .unwrap_or(fallback) } @@ -266,7 +266,7 @@ fn is_valid_subdomain(identifier: &str) -> bool { } fn is_valid_url(url: &str) -> bool { - url.starts_with("https://") || url.starts_with("http://") + url.starts_with("https://") } fn validate_company_identifier(identifier: Option<&str>) -> Result, AppError> { From 5ab91fcbed20af93dc57246ee6282c88e3cbbede Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:26:23 +0100 Subject: [PATCH 43/53] fix: replace CREATE_FIELD with DETECT_FIELDS across embed artifacts --- README.md | 4 +- documentation/IFRAME.md | 41 +--- examples/with-playwright-automation/README.md | 205 +----------------- .../example.config.json | 162 +------------- .../src/automation.ts | 108 +-------- .../with-playwright-automation/src/index.ts | 22 +- .../with-playwright-automation/src/schema.ts | 48 +--- react/README.md | 31 +-- react/src/hook.test.ts | 139 +----------- react/src/hook.tsx | 44 +--- react/src/index.test.tsx | 7 +- react/src/index.tsx | 8 +- 12 files changed, 52 insertions(+), 767 deletions(-) diff --git a/README.md b/README.md index 86e01e6..8e42f4e 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ SimplePDF Embed uses a **fully client-side architecture** for PDF processing: | Limitation | Description | Workaround | | --------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | -| **No server-side PDF generation** | Cannot generate PDFs from templates server-side | Use client-side field creation via `createField()` | +| **No server-side PDF generation** | Cannot generate PDFs from templates server-side | Use client-side field detection via `detectFields()` | | **No bulk processing** | Cannot process multiple PDFs in batch | Process sequentially or use dedicated server-side library | | **No programmatic PDF retrieval** | Cannot get modified PDF as Blob/Base64 in JS | Use webhooks + server storage for programmatic access | | **No persistent storage** | PDFs don't persist without user action | Use `companyIdentifier` for server-side submission storage | @@ -285,7 +285,7 @@ Currently, page manipulation (add/remove/re-arrange/rotate) is only available th | ---------------------- | ---------------------------------------------------------------------------------------------------------- | | `goTo` | Navigate to a specific page | | `selectTool` | Select a tool (`TEXT`, `BOXED_TEXT`, `CHECKBOX`, `PICTURE`, `SIGNATURE`) or `null` for cursor | -| `createField` | Create a field at a specified position | +| `detectFields` | Automatically detect form fields in the document | | `removeFields` | Remove fields by ID, by page, or all fields | | `getDocumentContent` | Extract text content from the document | | `submit` | Submit the document (with optional device download) | diff --git a/documentation/IFRAME.md b/documentation/IFRAME.md index f72c6a2..6566bbc 100644 --- a/documentation/IFRAME.md +++ b/documentation/IFRAME.md @@ -185,16 +185,8 @@ await sendEvent("GO_TO", { page: 3 }); // Select a tool await sendEvent("SELECT_TOOL", { tool: "TEXT" }); // or "CHECKBOX", "SIGNATURE", "PICTURE", "BOXED_TEXT", null -// Create a field -await sendEvent("CREATE_FIELD", { - type: "TEXT", - page: 1, - x: 100, - y: 700, - width: 200, - height: 30, - value: "Hello World", -}); +// Detect fields in the document +await sendEvent("DETECT_FIELDS", {}); // Remove all fields (or specific ones) await sendEvent("REMOVE_FIELDS", {}); // Remove all @@ -290,34 +282,11 @@ Select a drawing tool or return to cursor mode. | ------ | ---------------- | -------- | ---------------------------------------------------------------------------------------- | | `tool` | `string \| null` | Yes | `"TEXT"`, `"BOXED_TEXT"`, `"CHECKBOX"`, `"SIGNATURE"`, `"PICTURE"`, or `null` for cursor | -#### CREATE_FIELD - -Create a new field on the document. +#### DETECT_FIELDS -| Field | Type | Required | Description | -| -------- | -------- | -------- | --------------------------------------------------------------------- | -| `type` | `string` | Yes | `"TEXT"`, `"BOXED_TEXT"`, `"CHECKBOX"`, `"SIGNATURE"`, or `"PICTURE"` | -| `page` | `number` | Yes | Page number (1-indexed) | -| `x` | `number` | Yes | X coordinate (PDF points from left) | -| `y` | `number` | Yes | Y coordinate (PDF points from bottom) | -| `width` | `number` | Yes | Field width in PDF points | -| `height` | `number` | Yes | Field height in PDF points | -| `value` | `string` | No | Initial value (see value formats below) | +Automatically detect form fields in the document. -**Value formats by field type:** - -- `TEXT` / `BOXED_TEXT`: Plain text content -- `CHECKBOX`: `"checked"` or `"unchecked"` -- `PICTURE`: Data URL (base64) -- `SIGNATURE`: Data URL (base64 image) or plain text (generates a typed signature) - -**Response data:** - -```json -{ - "field_id": "f_kj8n2hd9x3m1p" -} -``` +_No data fields required._ #### REMOVE_FIELDS diff --git a/examples/with-playwright-automation/README.md b/examples/with-playwright-automation/README.md index 3d1a709..9f06f5d 100644 --- a/examples/with-playwright-automation/README.md +++ b/examples/with-playwright-automation/README.md @@ -1,13 +1,11 @@ # SimplePDF Editor Automation -Playwright-based CLI tool for programmatically creating and positioning fields in PDF documents using the SimplePDF editor. +Playwright-based CLI tool for automatically detecting form fields in PDF documents using the SimplePDF editor. ## Features -- Create TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, and PICTURE fields -- Position fields using PDF standard coordinates (bottom-left origin) -- Pre-fill field values including typed signatures -- Browser opens for visual inspection after field creation +- Automatically detect form fields +- Browser opens for visual inspection after detection ## Quick Start @@ -40,8 +38,7 @@ Create a JSON configuration file: ```json { - "document": "https://example.com/document.pdf", - "fields": [...] + "document": "https://example.com/document.pdf" } ``` @@ -52,204 +49,14 @@ Create a JSON configuration file: | URL | `"https://example.com/doc.pdf"` | | Local file | `"./documents/form.pdf"` | -## Field Types - -### TEXT - -Single-line text input. - -```json -{ - "type": "TEXT", - "x": 100, - "y": 700, - "width": 200, - "height": 20, - "page": 1, - "value": "John Doe" -} -``` - -### BOXED_TEXT - -Multi-line text with border. - -```json -{ - "type": "BOXED_TEXT", - "x": 100, - "y": 600, - "width": 300, - "height": 100, - "page": 1, - "value": "Additional notes here..." -} -``` - -### CHECKBOX - -Checkable box. Must be square (equal width/height). - -```json -{ - "type": "CHECKBOX", - "x": 100, - "y": 550, - "width": 12, - "height": 12, - "page": 1, - "value": true -} -``` - -### SIGNATURE - -Signature field with multiple value formats. - -```json -{ - "type": "SIGNATURE", - "x": 100, - "y": 450, - "width": 200, - "height": 60, - "page": 1, - "value": "John Doe" -} -``` - -**Value formats:** - -| Format | Example | Result | -|--------|---------|--------| -| Plain text | `"John Doe"` | Typed signature (cursive font) | -| URL | `"https://example.com/sig.png"` | Drawn signature from image | -| Data URL | `"data:image/png;base64,..."` | Drawn signature from base64 | -| Local file | `"./signatures/john.png"` | Drawn signature from file | - -### PICTURE - -Image field. - -```json -{ - "type": "PICTURE", - "x": 100, - "y": 300, - "width": 150, - "height": 150, - "page": 1, - "value": "https://example.com/photo.jpg" -} -``` - -**Value formats:** URL, data URL, or local file path. - -## Coordinate System - -Uses PDF standard coordinates: - -``` -┌─────────────────────────────┐ -│ │ ↑ -│ │ │ -│ PDF Page │ │ Y increases -│ │ │ -│ │ │ -└─────────────────────────────┘ -(0,0) ───────────────────────→ - X increases -``` - -- **Origin**: Bottom-left corner of page -- **Units**: Points (1/72 inch) -- **Y-axis**: Increases upward - -## Examples - -### Basic Form Fill - -```json -{ - "document": "https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf", - "fields": [ - { - "type": "TEXT", - "x": 72, - "y": 700, - "width": 200, - "height": 14, - "page": 1, - "value": "John" - }, - { - "type": "TEXT", - "x": 320, - "y": 700, - "width": 200, - "height": 14, - "page": 1, - "value": "Doe" - }, - { - "type": "SIGNATURE", - "x": 72, - "y": 100, - "width": 200, - "height": 60, - "page": 1, - "value": "John Doe" - } - ] -} -``` - -### Multi-Page Document - -```json -{ - "document": "./documents/multi-page.pdf", - "fields": [ - { - "type": "TEXT", - "x": 72, - "y": 700, - "width": 200, - "height": 14, - "page": 1, - "value": "Page 1 content" - }, - { - "type": "TEXT", - "x": 72, - "y": 700, - "width": 200, - "height": 14, - "page": 2, - "value": "Page 2 content" - }, - { - "type": "SIGNATURE", - "x": 72, - "y": 100, - "width": 200, - "height": 60, - "page": 3, - "value": "Final Signature" - } - ] -} -``` - ## How It Works The tool uses the SimplePDF editor's iframe postMessage API: 1. Embeds the editor in an iframe 2. Waits for `DOCUMENT_LOADED` event -3. Sends `REMOVE_FIELDS` to remove existing fields -4. Sends `CREATE_FIELD` for each configured field -5. Leaves browser open for inspection +3. Sends `DETECT_FIELDS` to automatically detect form fields +4. Leaves browser open for inspection ## Requirements diff --git a/examples/with-playwright-automation/example.config.json b/examples/with-playwright-automation/example.config.json index 96f2fc9..338eb7d 100644 --- a/examples/with-playwright-automation/example.config.json +++ b/examples/with-playwright-automation/example.config.json @@ -1,163 +1,3 @@ { - "document": "https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf", - "fields": [ - { - "type": "TEXT", - "x": 72, - "y": 700, - "width": 200, - "height": 14, - "page": 1, - "value": "John" - }, - { - "type": "TEXT", - "x": 320, - "y": 700, - "width": 200, - "height": 14, - "page": 1, - "value": "Doe" - }, - { - "type": "TEXT", - "x": 72, - "y": 650, - "width": 200, - "height": 14, - "page": 1, - "value": "john.doe@example.com" - }, - { - "type": "TEXT", - "x": 320, - "y": 650, - "width": 200, - "height": 14, - "page": 1, - "value": "+1 555-123-4567" - }, - { - "type": "BOXED_TEXT", - "x": 72, - "y": 600, - "width": 150, - "height": 14, - "page": 1, - "value": "2024-01-15" - }, - { - "type": "BOXED_TEXT", - "x": 320, - "y": 600, - "width": 150, - "height": 14, - "page": 1, - "value": "REF-001" - }, - { - "type": "CHECKBOX", - "x": 72, - "y": 550, - "width": 12, - "height": 12, - "page": 1, - "value": true - }, - { - "type": "CHECKBOX", - "x": 320, - "y": 550, - "width": 12, - "height": 12, - "page": 1, - "value": false - }, - { - "type": "SIGNATURE", - "x": 72, - "y": 450, - "width": 200, - "height": 60, - "page": 1 - }, - { - "type": "PICTURE", - "x": 320, - "y": 450, - "width": 100, - "height": 100, - "page": 1 - }, - { - "type": "TEXT", - "x": 72, - "y": 700, - "width": 200, - "height": 14, - "page": 2, - "value": "Additional Notes" - }, - { - "type": "TEXT", - "x": 320, - "y": 700, - "width": 200, - "height": 14, - "page": 2, - "value": "Section B" - }, - { - "type": "BOXED_TEXT", - "x": 72, - "y": 650, - "width": 200, - "height": 14, - "page": 2, - "value": "ABC-123" - }, - { - "type": "BOXED_TEXT", - "x": 320, - "y": 650, - "width": 200, - "height": 14, - "page": 2, - "value": "XYZ-789" - }, - { - "type": "CHECKBOX", - "x": 72, - "y": 600, - "width": 12, - "height": 12, - "page": 2, - "value": true - }, - { - "type": "CHECKBOX", - "x": 320, - "y": 600, - "width": 12, - "height": 12, - "page": 2, - "value": true - }, - { - "type": "SIGNATURE", - "x": 72, - "y": 450, - "width": 200, - "height": 60, - "page": 2 - }, - { - "type": "PICTURE", - "x": 320, - "y": 450, - "width": 100, - "height": 100, - "page": 2 - } - ] + "document": "https://cdn.simplepdf.com/simple-pdf/assets/forms/urla-1003.pdf" } diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index fca0ac6..5ec82f2 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -4,9 +4,8 @@ import * as path from 'path'; import type { AutomationConfig } from './schema'; type AutomationErrorCode = - | 'document_load_failed' - | 'field_creation_failed' - | 'remove_fields_failed'; + | 'detect_fields_failed' + | 'document_load_failed'; type AutomationResult = | { success: true; data: null } @@ -163,38 +162,6 @@ const setupIframePage = async ({ }; }; -const resolveValueToString = ({ value }: { value: string }): string => { - if (value.startsWith('data:') || value.startsWith('http://') || value.startsWith('https://')) { - return value; - } - - const absolutePath = path.isAbsolute(value) ? value : path.resolve(process.cwd(), value); - - if (!fs.existsSync(absolutePath)) { - throw new Error(`File not found: ${absolutePath}`); - } - - const buffer = fs.readFileSync(absolutePath); - const ext = path.extname(absolutePath).toLowerCase(); - const mimeType = (() => { - switch (ext) { - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.png': - return 'image/png'; - case '.gif': - return 'image/gif'; - case '.webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${buffer.toString('base64')}`; -}; - const runAutomation = async ({ config, baseUrl }: RunAutomationArgs): Promise => { let browser: Browser | null = null; @@ -217,74 +184,21 @@ const runAutomation = async ({ config, baseUrl }: RunAutomationArgs): Promise { - if (field.value === undefined) { - return undefined; - } - - if (typeof field.value === 'boolean') { - return field.value; - } - - if (field.type === 'PICTURE' || field.type === 'SIGNATURE') { - return resolveValueToString({ value: field.value }); - } - - return field.value; - })(); - - const requestId = await sendEvent({ - type: 'CREATE_FIELD', - data: { - type: field.type, - x: field.x, - y: field.y, - width: field.width, - height: field.height, - page: field.page, - ...(fieldValue !== undefined ? { value: fieldValue } : {}), - }, - }); - - const result = await waitForEvent('REQUEST_RESULT', { requestId }); - - if (!isRequestResultData(result.event.data) || !result.event.data.result.success) { - const errorMessage = isRequestResultData(result.event.data) - ? result.event.data.result.error?.message ?? 'Unknown error' - : 'Invalid response'; - return { - success: false, - error: { - code: 'field_creation_failed', - message: `Failed to create ${field.type} field at page ${field.page}: ${errorMessage}`, - }, - }; - } - - console.log(` [${i + 1}/${config.fields.length}] Created ${field.type} on page ${field.page}`); - } + console.log('Fields detected'); - console.log('All fields created'); await page.pause(); return { success: true, data: null }; diff --git a/examples/with-playwright-automation/src/index.ts b/examples/with-playwright-automation/src/index.ts index 41bc0b9..d49df1c 100644 --- a/examples/with-playwright-automation/src/index.ts +++ b/examples/with-playwright-automation/src/index.ts @@ -26,28 +26,9 @@ Options: Configuration file format: { - "document": "https://example.com/document.pdf", - "fields": [ - { - "type": "TEXT", - "x": 100, - "y": 700, - "width": 200, - "height": 20, - "page": 1, - "value": "Hello World" - } - ] + "document": "https://example.com/document.pdf" } -Field types: TEXT, BOXED_TEXT, SIGNATURE, PICTURE, CHECKBOX - -Coordinate System: - Uses PDF standard coordinates: - - Origin at bottom-left corner of page - - Y increases upward - - Units in points (1/72 inch) - Examples: npx tsx src/index.ts example.config.json npx tsx src/index.ts example.config.json --company-identifier yourcompany @@ -133,7 +114,6 @@ const main = async (): Promise => { console.log('Starting automation...'); console.log(`Document: ${config.document}`); - console.log(`Fields: ${config.fields.length}`); console.log(`Editor: ${baseUrl}`); console.log(''); diff --git a/examples/with-playwright-automation/src/schema.ts b/examples/with-playwright-automation/src/schema.ts index f438992..cdb6ade 100644 --- a/examples/with-playwright-automation/src/schema.ts +++ b/examples/with-playwright-automation/src/schema.ts @@ -1,54 +1,8 @@ import { z } from 'zod'; -const FieldType = z.enum(['TEXT', 'BOXED_TEXT', 'SIGNATURE', 'PICTURE', 'CHECKBOX']); -type FieldType = z.infer; - -const BaseField = z.object({ - x: z.number().describe('X coordinate in PDF points from bottom-left origin'), - y: z.number().describe('Y coordinate in PDF points from bottom-left origin'), - width: z.number().positive().describe('Width in PDF points'), - height: z.number().positive().describe('Height in PDF points'), - page: z.number().int().positive().describe('1-indexed page number'), -}); - -const TextField = BaseField.extend({ - type: z.literal('TEXT'), - value: z.string().optional(), -}); - -const BoxedTextField = BaseField.extend({ - type: z.literal('BOXED_TEXT'), - value: z.string().optional(), -}); - -const CheckboxField = BaseField.extend({ - type: z.literal('CHECKBOX'), - value: z.boolean().optional(), -}); - -const SignatureField = BaseField.extend({ - type: z.literal('SIGNATURE'), - value: z.string().optional().describe('File path, URL, data URL, or plain text (generates typed signature)'), -}); - -const PictureField = BaseField.extend({ - type: z.literal('PICTURE'), - value: z.string().optional().describe('File path, URL, or data URL'), -}); - -const FieldConfig = z.discriminatedUnion('type', [ - TextField, - BoxedTextField, - CheckboxField, - SignatureField, - PictureField, -]); -type FieldConfig = z.infer; - const AutomationConfig = z.object({ document: z.string().describe('URL or local file path to PDF'), - fields: z.array(FieldConfig), }); type AutomationConfig = z.infer; -export { FieldType, FieldConfig, AutomationConfig }; +export { AutomationConfig }; diff --git a/react/README.md b/react/README.md index e1e26ad..cf2a666 100644 --- a/react/README.md +++ b/react/README.md @@ -130,7 +130,7 @@ Use `const { embedRef, actions } = useEmbed();` to programmatically control the | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `actions.goTo({ page })` | Navigate to a specific page | | `actions.selectTool(toolType)` | Select a tool: `'TEXT'`, `'BOXED_TEXT'`, `'CHECKBOX'`, `'PICTURE'`, `'SIGNATURE'`, or `null` to deselect (`CURSOR`) | -| `actions.createField(options)` | Create a field at specified position (see below) | +| `actions.detectFields()` | Automatically detect form fields in the document | | `actions.removeFields(options?)` | Remove fields by `fieldIds` or `page`, or all fields if no options | | `actions.getDocumentContent({ extractionMode })` | Extract document content (`extractionMode: 'auto'` or `'ocr'`) | | `actions.submit({ downloadCopyOnDevice })` | Submit the document | @@ -158,18 +158,10 @@ const Editor = () => { } }; - const handleCreateTextField = async () => { - const result = await actions.createField({ - type: 'TEXT', - page: 1, - x: 100, - y: 200, - width: 150, - height: 30, - value: 'Hello World', - }); + const handleDetectFields = async () => { + const result = await actions.detectFields(); if (result.success) { - console.log('Created field:', result.data.field_id); + console.log('Fields detected!'); } }; @@ -177,7 +169,7 @@ const Editor = () => { <> - + { }; ``` -#### `createField` options - -The `createField` action uses a discriminated union based on field type: - -| Type | `value` format | -| --------------------- | ----------------------------------------------------------- | -| `TEXT` / `BOXED_TEXT` | Plain text content | -| `CHECKBOX` | `'checked'` or `'unchecked'` | -| `PICTURE` | Data URL (base64) | -| `SIGNATURE` | Data URL (base64) or plain text (generates typed signature) | - -All field types share these base options: `page`, `x`, `y`, `width`, `height` (coordinates in PDF points, origin at bottom-left). - See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, downloading, and server-side storage options. ### Available props diff --git a/react/src/hook.test.ts b/react/src/hook.test.ts index 1d3da97..0eaddfe 100644 --- a/react/src/hook.test.ts +++ b/react/src/hook.test.ts @@ -247,16 +247,9 @@ describe('useEmbed', () => { expect(actionResult).toEqual(expectedError); }); - it('createField returns error when embedRef not attached', async () => { + it('detectFields returns error when embedRef not attached', async () => { const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.createField({ - type: 'TEXT', - page: 1, - x: 0, - y: 0, - width: 100, - height: 20, - }); + const actionResult = await result.current.actions.detectFields(); expect(actionResult).toEqual(expectedError); }); @@ -287,7 +280,7 @@ describe('useEmbed', () => { const spies = { goTo: vi.fn().mockResolvedValue({ success: true }), selectTool: vi.fn().mockResolvedValue({ success: true }), - createField: vi.fn().mockResolvedValue({ success: true }), + detectFields: vi.fn().mockResolvedValue({ success: true }), removeFields: vi.fn().mockResolvedValue({ success: true }), getDocumentContent: vi.fn().mockResolvedValue({ success: true }), submit: vi.fn().mockResolvedValue({ success: true }), @@ -321,15 +314,14 @@ describe('useEmbed', () => { expect(actionResult).toEqual({ success: true }); }); - it('createField delegates to ref.createField', async () => { + it('detectFields delegates to ref.detectFields', async () => { const { result } = renderHook(() => useEmbed()); const { ref, spies } = createMockEmbedRef(); (result.current.embedRef as React.MutableRefObject).current = ref; - const fieldOptions = { type: 'TEXT' as const, page: 1, x: 0, y: 0, width: 100, height: 20 }; - const actionResult = await result.current.actions.createField(fieldOptions); + const actionResult = await result.current.actions.detectFields(); - expect(spies.createField).toHaveBeenCalledWith(fieldOptions); + expect(spies.detectFields).toHaveBeenCalled(); expect(actionResult).toEqual({ success: true }); }); @@ -384,40 +376,6 @@ describe('Type assertions', () => { type ExpectedToolType = 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; - type ExpectedBaseFieldOptions = { - page: number; - x: number; - y: number; - width: number; - height: number; - }; - - type ExpectedTextFieldOptions = ExpectedBaseFieldOptions & { - type: 'TEXT' | 'BOXED_TEXT'; - value?: string; - }; - - type ExpectedCheckboxFieldOptions = ExpectedBaseFieldOptions & { - type: 'CHECKBOX'; - value?: 'checked' | 'unchecked'; - }; - - type ExpectedPictureFieldOptions = ExpectedBaseFieldOptions & { - type: 'PICTURE'; - value?: string; - }; - - type ExpectedSignatureFieldOptions = ExpectedBaseFieldOptions & { - type: 'SIGNATURE'; - value?: string; - }; - - type ExpectedCreateFieldOptions = - | ExpectedTextFieldOptions - | ExpectedCheckboxFieldOptions - | ExpectedPictureFieldOptions - | ExpectedSignatureFieldOptions; - type ExpectedErrorResult = { success: false; error: { code: string; message: string }; @@ -440,11 +398,9 @@ describe('Type assertions', () => { expectTypeOf().returns.resolves.toExtend(); }); - it('createField accepts CreateFieldOptions and returns ActionResult with field_id', () => { - expectTypeOf().parameter(0).toEqualTypeOf(); - expectTypeOf().returns.resolves.toExtend< - ExpectedActionResult<{ field_id: string }> - >(); + it('detectFields accepts no arguments and returns ActionResult', () => { + expectTypeOf().parameters.toEqualTypeOf<[]>(); + expectTypeOf().returns.resolves.toExtend(); }); it('removeFields accepts optional { fieldIds?, page? } and returns ActionResult with removed_count', () => { @@ -471,81 +427,4 @@ describe('Type assertions', () => { }); }); - describe('createField discriminated union', () => { - it('TEXT field options are accepted', () => { - const textField = { - type: 'TEXT' as const, - page: 1, - x: 0, - y: 0, - width: 100, - height: 20, - value: 'hello', - }; - expectTypeOf(textField).toExtend(); - }); - - it('BOXED_TEXT field options are accepted', () => { - const boxedTextField = { - type: 'BOXED_TEXT' as const, - page: 1, - x: 0, - y: 0, - width: 100, - height: 20, - value: 'hello', - }; - expectTypeOf(boxedTextField).toExtend(); - }); - - it('CHECKBOX field accepts only checked/unchecked values', () => { - const checkedField = { - type: 'CHECKBOX' as const, - page: 1, - x: 0, - y: 0, - width: 20, - height: 20, - value: 'checked' as const, - }; - expectTypeOf(checkedField).toExtend(); - - const uncheckedField = { - type: 'CHECKBOX' as const, - page: 1, - x: 0, - y: 0, - width: 20, - height: 20, - value: 'unchecked' as const, - }; - expectTypeOf(uncheckedField).toExtend(); - }); - - it('PICTURE field options are accepted', () => { - const pictureField = { - type: 'PICTURE' as const, - page: 1, - x: 0, - y: 0, - width: 100, - height: 100, - value: 'data:image/png;base64,...', - }; - expectTypeOf(pictureField).toExtend(); - }); - - it('SIGNATURE field options are accepted', () => { - const signatureField = { - type: 'SIGNATURE' as const, - page: 1, - x: 0, - y: 0, - width: 150, - height: 50, - value: 'John Doe', - }; - expectTypeOf(signatureField).toExtend(); - }); - }); }); diff --git a/react/src/hook.tsx b/react/src/hook.tsx index 9b9eec8..e005dbe 100644 --- a/react/src/hook.tsx +++ b/react/src/hook.tsx @@ -7,36 +7,6 @@ type ExtractionMode = 'auto' | 'ocr'; type ToolType = 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; -type BaseFieldOptions = { - page: number; - x: number; - y: number; - width: number; - height: number; -}; - -type TextFieldOptions = BaseFieldOptions & { - type: 'TEXT' | 'BOXED_TEXT'; - value?: string; -}; - -type CheckboxFieldOptions = BaseFieldOptions & { - type: 'CHECKBOX'; - value?: 'checked' | 'unchecked'; -}; - -type PictureFieldOptions = BaseFieldOptions & { - type: 'PICTURE'; - value?: string; // Data URL (base64) -}; - -type SignatureFieldOptions = BaseFieldOptions & { - type: 'SIGNATURE'; - value?: string; // Data URL (base64) or plain text (generates typed signature) -}; - -export type CreateFieldOptions = TextFieldOptions | CheckboxFieldOptions | PictureFieldOptions | SignatureFieldOptions; - type ErrorCodePrefix = 'bad_request' | 'unexpected' | 'forbidden'; type ErrorResult = { @@ -62,16 +32,12 @@ type RemoveFieldsResult = { removed_count: number; }; -type CreateFieldResult = { - field_id: string; -}; - export type EmbedActions = { goTo: (options: { page: number }) => Promise; selectTool: (toolType: ToolType | null) => Promise; - createField: (options: CreateFieldOptions) => Promise>; + detectFields: () => Promise; removeFields: (options?: { fieldIds?: string[]; page?: number }) => Promise>; @@ -177,9 +143,9 @@ export const useEmbed = (): { embedRef: React.RefObject; ac [], ); - const handleCreateField = React.useCallback( - createAction<[CreateFieldOptions], CreateFieldResult>(async (ref, options) => { - return ref.createField(options); + const handleDetectFields = React.useCallback( + createAction(async (ref) => { + return ref.detectFields(); }), [], ); @@ -210,7 +176,7 @@ export const useEmbed = (): { embedRef: React.RefObject; ac actions: { goTo: handleGoTo, selectTool: handleSelectTool, - createField: handleCreateField, + detectFields: handleDetectFields, removeFields: handleRemoveFields, getDocumentContent: handleGetDocumentContent, submit: handleSubmit, diff --git a/react/src/index.test.tsx b/react/src/index.test.tsx index 619c8e8..087bf96 100644 --- a/react/src/index.test.tsx +++ b/react/src/index.test.tsx @@ -311,7 +311,7 @@ describe('EmbedPDF', () => { expect(typeof ref.current?.goTo).toBe('function'); expect(typeof ref.current?.selectTool).toBe('function'); - expect(typeof ref.current?.createField).toBe('function'); + expect(typeof ref.current?.detectFields).toBe('function'); expect(typeof ref.current?.removeFields).toBe('function'); expect(typeof ref.current?.getDocumentContent).toBe('function'); expect(typeof ref.current?.submit).toBe('function'); @@ -321,10 +321,7 @@ describe('EmbedPDF', () => { it.each([ { action: 'goTo' as const, args: { page: 1 } }, { action: 'selectTool' as const, args: 'TEXT' as const }, - { - action: 'createField' as const, - args: { type: 'TEXT' as const, page: 1, x: 0, y: 0, width: 100, height: 20 }, - }, + { action: 'detectFields' as const, args: undefined }, { action: 'removeFields' as const, args: {} }, { action: 'getDocumentContent' as const, args: {} }, { action: 'submit' as const, args: { downloadCopyOnDevice: false } }, diff --git a/react/src/index.tsx b/react/src/index.tsx index 2385134..bb27596 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -178,14 +178,14 @@ export const EmbedPDF = React.forwardRef((props, ref) => { }); }, []); - const createField: EmbedActions['createField'] = React.useCallback(async (options) => { + const detectFields: EmbedActions['detectFields'] = React.useCallback(async () => { if (!iframeRef.current) { return { success: false, error: { code: 'unexpected:iframe_not_available', message: 'Iframe not available' } }; } await ensureEditorReady(); return sendEvent(iframeRef.current, { - type: 'CREATE_FIELD', - data: options, + type: 'DETECT_FIELDS', + data: {}, }); }, []); @@ -226,7 +226,7 @@ export const EmbedPDF = React.forwardRef((props, ref) => { loadDocument, goTo, selectTool, - createField, + detectFields, removeFields, getDocumentContent, submit, From 254530cf5e2b97235ece3514389ed18ee462cd56 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:27:36 +0100 Subject: [PATCH 44/53] chore: add changeset for minor release (createField -> detectFields) --- .changeset/replace-create-field-with-detect-fields.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/replace-create-field-with-detect-fields.md diff --git a/.changeset/replace-create-field-with-detect-fields.md b/.changeset/replace-create-field-with-detect-fields.md new file mode 100644 index 0000000..8169351 --- /dev/null +++ b/.changeset/replace-create-field-with-detect-fields.md @@ -0,0 +1,5 @@ +--- +"@simplepdf/react-embed-pdf": minor +--- + +Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. From cdb04288fa25c7e169718dda9d2abc3549ed6f83 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:28:30 +0100 Subject: [PATCH 45/53] chore: bump changeset to major release --- .changeset/replace-create-field-with-detect-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/replace-create-field-with-detect-fields.md b/.changeset/replace-create-field-with-detect-fields.md index 8169351..bf67bef 100644 --- a/.changeset/replace-create-field-with-detect-fields.md +++ b/.changeset/replace-create-field-with-detect-fields.md @@ -1,5 +1,5 @@ --- -"@simplepdf/react-embed-pdf": minor +"@simplepdf/react-embed-pdf": major --- Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. From 0fbf4eb4b63a4a77e2f4db1f8acfd01851ddd28c Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:30:27 +0100 Subject: [PATCH 46/53] chore: add safe upgrade note to changeset --- .changeset/replace-create-field-with-detect-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/replace-create-field-with-detect-fields.md b/.changeset/replace-create-field-with-detect-fields.md index bf67bef..458753b 100644 --- a/.changeset/replace-create-field-with-detect-fields.md +++ b/.changeset/replace-create-field-with-detect-fields.md @@ -2,4 +2,4 @@ "@simplepdf/react-embed-pdf": major --- -Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. +Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. If you are not using `createField`, you can safely update to this new major version. From fce12e7d20b4a0735560c22124a0a155d707a2fd Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:30:53 +0100 Subject: [PATCH 47/53] chore: add migration snippet to changeset --- .../replace-create-field-with-detect-fields.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/replace-create-field-with-detect-fields.md b/.changeset/replace-create-field-with-detect-fields.md index 458753b..5b69de9 100644 --- a/.changeset/replace-create-field-with-detect-fields.md +++ b/.changeset/replace-create-field-with-detect-fields.md @@ -2,4 +2,14 @@ "@simplepdf/react-embed-pdf": major --- -Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. If you are not using `createField`, you can safely update to this new major version. +Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. + +If you are not using `actions.createField(...)` or `sendEvent("CREATE_FIELD", ...)`, you can safely update to this new major version. + +```ts +// Before (removed) +await actions.createField({ type: "TEXT", page: 1, x: 100, y: 700, width: 200, height: 30 }); + +// After +await actions.detectFields(); +``` From 92d9d3e002b6db76c0fdfc1d4e6b6a747371a458 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:40:20 +0100 Subject: [PATCH 48/53] chore: simplify automation to accept document URL/path inline --- examples/with-playwright-automation/README.md | 37 +++-------- .../example.config.json | 3 - .../with-playwright-automation/package.json | 3 +- .../src/automation.ts | 10 +-- .../with-playwright-automation/src/index.ts | 61 +++++-------------- .../with-playwright-automation/src/schema.ts | 8 --- 6 files changed, 27 insertions(+), 95 deletions(-) delete mode 100644 examples/with-playwright-automation/example.config.json delete mode 100644 examples/with-playwright-automation/src/schema.ts diff --git a/examples/with-playwright-automation/README.md b/examples/with-playwright-automation/README.md index 9f06f5d..e05fb3d 100644 --- a/examples/with-playwright-automation/README.md +++ b/examples/with-playwright-automation/README.md @@ -2,53 +2,34 @@ Playwright-based CLI tool for automatically detecting form fields in PDF documents using the SimplePDF editor. -## Features - -- Automatically detect form fields -- Browser opens for visual inspection after detection - ## Quick Start ```bash npm install -npx tsx src/index.ts example.config.json +npx tsx src/index.ts https://example.com/form.pdf ``` ## Usage ```bash -npx tsx src/index.ts [options] +npx tsx src/index.ts [options] + +Arguments: + document URL or local file path to a PDF Options: --company-identifier Your SimplePDF company identifier (default: embed) --help Show help ``` -### Using Your Company Identifier +### Examples ```bash -npx tsx src/index.ts config.json --company-identifier mycompany -``` - -This connects to `https://mycompany.simplepdf.com`. - -## Configuration - -Create a JSON configuration file: - -```json -{ - "document": "https://example.com/document.pdf" -} +npx tsx src/index.ts https://example.com/form.pdf +npx tsx src/index.ts ./documents/form.pdf +npx tsx src/index.ts https://example.com/form.pdf --company-identifier mycompany ``` -### Document Source - -| Format | Example | -|--------|---------| -| URL | `"https://example.com/doc.pdf"` | -| Local file | `"./documents/form.pdf"` | - ## How It Works The tool uses the SimplePDF editor's iframe postMessage API: diff --git a/examples/with-playwright-automation/example.config.json b/examples/with-playwright-automation/example.config.json deleted file mode 100644 index 338eb7d..0000000 --- a/examples/with-playwright-automation/example.config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "document": "https://cdn.simplepdf.com/simple-pdf/assets/forms/urla-1003.pdf" -} diff --git a/examples/with-playwright-automation/package.json b/examples/with-playwright-automation/package.json index 56bbc6e..cd18e23 100644 --- a/examples/with-playwright-automation/package.json +++ b/examples/with-playwright-automation/package.json @@ -13,8 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "playwright": "^1.49.0", - "zod": "^4.3.4" + "playwright": "^1.49.0" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index 5ec82f2..e8bc706 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -1,7 +1,6 @@ import { chromium, Browser, Page } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; -import type { AutomationConfig } from './schema'; type AutomationErrorCode = | 'detect_fields_failed' @@ -11,11 +10,6 @@ type AutomationResult = | { success: true; data: null } | { success: false; error: { code: AutomationErrorCode; message: string } }; -type RunAutomationArgs = { - config: AutomationConfig; - baseUrl: string; -}; - type IframeEvent = { type: string; data?: Record; @@ -162,7 +156,7 @@ const setupIframePage = async ({ }; }; -const runAutomation = async ({ config, baseUrl }: RunAutomationArgs): Promise => { +const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: string }): Promise => { let browser: Browser | null = null; try { @@ -174,7 +168,7 @@ const runAutomation = async ({ config, baseUrl }: RunAutomationArgs): Promise { console.log(` -Usage: npx tsx src/index.ts [options] +Usage: npx tsx src/index.ts [options] Arguments: - config.json Path to JSON configuration file + document URL or local file path to a PDF Options: --company-identifier Your SimplePDF company identifier (default: embed) --help Show this help message -Configuration file format: -{ - "document": "https://example.com/document.pdf" -} - Examples: - npx tsx src/index.ts example.config.json - npx tsx src/index.ts example.config.json --company-identifier yourcompany + npx tsx src/index.ts https://example.com/form.pdf + npx tsx src/index.ts ./documents/form.pdf + npx tsx src/index.ts https://example.com/form.pdf --company-identifier yourcompany `); }; type ParsedArgs = { - configPath: string | null; + document: string | null; baseUrl: string; showHelp: boolean; }; const parseArgs = (): ParsedArgs => { const args = process.argv.slice(2); - let configPath: string | null = null; + let document: string | null = null; let companyIdentifier = DEFAULT_COMPANY_IDENTIFIER; let baseUrl: string | null = null; let showHelp = false; @@ -67,57 +58,35 @@ const parseArgs = (): ParsedArgs => { } if (!arg?.startsWith('-')) { - configPath = arg ?? null; + document = arg ?? null; } } const resolvedBaseUrl = baseUrl ?? `https://${companyIdentifier}.simplepdf.com`; - return { configPath, baseUrl: resolvedBaseUrl, showHelp }; -}; - -const loadConfig = ({ configPath }: { configPath: string }): AutomationConfig => { - const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(process.cwd(), configPath); - - if (!fs.existsSync(absolutePath)) { - throw new Error(`Configuration file not found: ${absolutePath}`); - } - - const content = fs.readFileSync(absolutePath, 'utf-8'); - const parsed = JSON.parse(content) as unknown; - - return parsed as AutomationConfig; + return { document, baseUrl: resolvedBaseUrl, showHelp }; }; const main = async (): Promise => { - const { configPath, baseUrl, showHelp } = parseArgs(); + const { document, baseUrl, showHelp } = parseArgs(); if (showHelp) { printUsage(); process.exit(EXIT_CODES.SUCCESS); } - if (!configPath) { - console.error('Error: Configuration file path is required'); + if (!document) { + console.error('Error: document URL or file path is required'); printUsage(); process.exit(EXIT_CODES.INVALID_ARGS); } - let config: AutomationConfig; - try { - config = loadConfig({ configPath }); - } catch (e) { - const error = e as Error; - console.error(`Error loading configuration: ${error.message}`); - process.exit(EXIT_CODES.FILE_NOT_FOUND); - } - console.log('Starting automation...'); - console.log(`Document: ${config.document}`); + console.log(`Document: ${document}`); console.log(`Editor: ${baseUrl}`); console.log(''); - const result = await runAutomation({ config, baseUrl }); + const result = await runAutomation({ document, baseUrl }); if (!result.success) { console.error(`Automation failed: [${result.error.code}] ${result.error.message}`); diff --git a/examples/with-playwright-automation/src/schema.ts b/examples/with-playwright-automation/src/schema.ts deleted file mode 100644 index cdb6ade..0000000 --- a/examples/with-playwright-automation/src/schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -const AutomationConfig = z.object({ - document: z.string().describe('URL or local file path to PDF'), -}); -type AutomationConfig = z.infer; - -export { AutomationConfig }; From c0d66b3d2de06cf13f7aff7d3a088a7b7fa3bedc Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:42:10 +0100 Subject: [PATCH 49/53] fix: load local files via LOAD_DOCUMENT data URL instead of invalid localFile param --- .../src/automation.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index e8bc706..7f8b957 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -156,6 +156,19 @@ const setupIframePage = async ({ }; }; +const isUrl = (value: string): boolean => value.startsWith('http://') || value.startsWith('https://'); + +const readFileAsDataUrl = ({ filePath }: { filePath: string }): string => { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`File not found: ${absolutePath}`); + } + + const buffer = fs.readFileSync(absolutePath); + return `data:application/pdf;base64,${buffer.toString('base64')}`; +}; + const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: string }): Promise => { let browser: Browser | null = null; @@ -168,12 +181,25 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: const page = await context.newPage(); - const editorUrl = buildEditorUrl({ document, baseUrl }); + const editorUrl = isUrl(document) + ? `${baseUrl}/editor?open=${encodeURIComponent(document)}` + : `${baseUrl}/editor`; + const { sendEvent, waitForEvent, waitForDocumentLoaded } = await setupIframePage({ page, editorUrl, }); + if (!isUrl(document)) { + console.log('Waiting for editor to be ready...'); + await waitForEvent('EDITOR_READY'); + console.log('Editor ready, loading local file...'); + + const dataUrl = readFileAsDataUrl({ filePath: document }); + const fileName = path.basename(document); + await sendEvent({ type: 'LOAD_DOCUMENT', data: { data_url: dataUrl, name: fileName } }); + } + console.log('Waiting for document to load...'); await waitForDocumentLoaded(); console.log('Document loaded'); @@ -208,18 +234,4 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: } }; -const buildEditorUrl = ({ document, baseUrl }: { document: string; baseUrl: string }): string => { - if (document.startsWith('http://') || document.startsWith('https://')) { - return `${baseUrl}/editor?open=${encodeURIComponent(document)}`; - } - - const absolutePath = path.isAbsolute(document) ? document : path.resolve(process.cwd(), document); - - if (!fs.existsSync(absolutePath)) { - throw new Error(`File not found: ${absolutePath}`); - } - - return `${baseUrl}/editor?localFile=${encodeURIComponent(absolutePath)}`; -}; - export { runAutomation, AutomationResult }; From 3b1e84e2e69dd6ef414867ed0796ab556e89dfe2 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:47:40 +0100 Subject: [PATCH 50/53] fix: use loadingPlaceholder for local files, remove EDITOR_READY wait --- examples/with-playwright-automation/src/automation.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index 7f8b957..813303c 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -183,7 +183,7 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: const editorUrl = isUrl(document) ? `${baseUrl}/editor?open=${encodeURIComponent(document)}` - : `${baseUrl}/editor`; + : `${baseUrl}/editor?loadingPlaceholder=true`; const { sendEvent, waitForEvent, waitForDocumentLoaded } = await setupIframePage({ page, @@ -191,10 +191,7 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: }); if (!isUrl(document)) { - console.log('Waiting for editor to be ready...'); - await waitForEvent('EDITOR_READY'); - console.log('Editor ready, loading local file...'); - + console.log('Loading local file...'); const dataUrl = readFileAsDataUrl({ filePath: document }); const fileName = path.basename(document); await sendEvent({ type: 'LOAD_DOCUMENT', data: { data_url: dataUrl, name: fileName } }); From 9cc20651430f8e369fe48ce916b60ca799454ddf Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:48:40 +0100 Subject: [PATCH 51/53] fix: wait for EDITOR_READY before sending LOAD_DOCUMENT for local files --- examples/with-playwright-automation/src/automation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index 813303c..3e2bb98 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -191,6 +191,9 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: }); if (!isUrl(document)) { + console.log('Waiting for editor to be ready...'); + await waitForEvent('EDITOR_READY'); + console.log('Loading local file...'); const dataUrl = readFileAsDataUrl({ filePath: document }); const fileName = path.basename(document); From 51c84440b9bc556a7370042877c300cb0adabb17 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:53:03 +0100 Subject: [PATCH 52/53] feat: log detected field count from DETECT_FIELDS response --- examples/with-playwright-automation/src/automation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/with-playwright-automation/src/automation.ts b/examples/with-playwright-automation/src/automation.ts index 3e2bb98..56dbf80 100644 --- a/examples/with-playwright-automation/src/automation.ts +++ b/examples/with-playwright-automation/src/automation.ts @@ -26,6 +26,7 @@ type RequestResultData = { request_id: string; result: { success: boolean; + data?: { detected_count?: number }; error?: { code: string; message: string }; }; }; @@ -217,7 +218,8 @@ const runAutomation = async ({ document, baseUrl }: { document: string; baseUrl: error: { code: 'detect_fields_failed', message: `Failed to detect fields: ${errorMessage}` }, }; } - console.log('Fields detected'); + const detectedCount = detectResult.event.data.result.data?.detected_count ?? 0; + console.log(`Fields detected: ${detectedCount}`); await page.pause(); From d429a60c415ee40e72870c0c287b0ea6eefaa65e Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 15:56:31 +0100 Subject: [PATCH 53/53] chore: fix prettier formatting in hook.test.ts --- react/src/hook.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/react/src/hook.test.ts b/react/src/hook.test.ts index 0eaddfe..36d7226 100644 --- a/react/src/hook.test.ts +++ b/react/src/hook.test.ts @@ -426,5 +426,4 @@ describe('Type assertions', () => { expectTypeOf().returns.resolves.toExtend(); }); }); - });