personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky

feat: add CORS support for server and worker

+561 -29
+1
.gitignore
··· 20 20 # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 21 .idea/ 22 22 .vscode/ 23 + dist/
+17 -12
Cargo.lock
··· 108 108 109 109 [[package]] 110 110 name = "axum" 111 - version = "0.7.9" 111 + version = "0.8.7" 112 112 source = "registry+https://github.com/rust-lang/crates.io-index" 113 - checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 113 + checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" 114 114 dependencies = [ 115 - "async-trait", 116 115 "axum-core", 117 116 "bytes", 117 + "form_urlencoded", 118 118 "futures-util", 119 119 "http", 120 120 "http-body", ··· 122 122 "hyper", 123 123 "hyper-util", 124 124 "itoa", 125 - "matchit", 125 + "matchit 0.8.4", 126 126 "memchr", 127 127 "mime", 128 128 "percent-encoding", 129 129 "pin-project-lite", 130 - "rustversion", 131 - "serde", 130 + "serde_core", 132 131 "serde_json", 133 132 "serde_path_to_error", 134 133 "serde_urlencoded", ··· 142 141 143 142 [[package]] 144 143 name = "axum-core" 145 - version = "0.4.5" 144 + version = "0.5.5" 146 145 source = "registry+https://github.com/rust-lang/crates.io-index" 147 - checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 146 + checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" 148 147 dependencies = [ 149 - "async-trait", 150 148 "bytes", 151 - "futures-util", 149 + "futures-core", 152 150 "http", 153 151 "http-body", 154 152 "http-body-util", 155 153 "mime", 156 154 "pin-project-lite", 157 - "rustversion", 158 155 "sync_wrapper", 159 156 "tower-layer", 160 157 "tower-service", ··· 988 985 checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 989 986 990 987 [[package]] 988 + name = "matchit" 989 + version = "0.8.4" 990 + source = "registry+https://github.com/rust-lang/crates.io-index" 991 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 992 + 993 + [[package]] 991 994 name = "mediatype" 992 995 version = "0.19.20" 993 996 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1165 1168 "serde_json", 1166 1169 "tempfile", 1167 1170 "tokio", 1171 + "tower", 1172 + "tower-http", 1168 1173 ] 1169 1174 1170 1175 [[package]] ··· 2306 2311 "http", 2307 2312 "http-body", 2308 2313 "js-sys", 2309 - "matchit", 2314 + "matchit 0.7.3", 2310 2315 "pin-project", 2311 2316 "serde", 2312 2317 "serde-wasm-bindgen",
+79
DEPLOYMENT.md
··· 46 46 ./target/release/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080 47 47 ``` 48 48 49 + ### CORS Configuration for Self-Hosted Server 50 + 51 + The HTTP server supports CORS configuration via `config.toml`. Add a `[cors]` section: 52 + 53 + ```toml 54 + [cors] 55 + # List of allowed origins for cross-origin requests 56 + allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"] 57 + 58 + # Optional development key for local testing 59 + dev_key = "your-secret-dev-key" 60 + ``` 61 + 62 + CORS features: 63 + 64 + - **Exact matching**: `http://localhost:4321` only allows that specific origin 65 + - **Same-root-domain**: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc. 66 + - **Dev key**: Requests with `X-Local-Dev-Key` header matching the configured key bypass origin checks 67 + 68 + The PAI server handles CORS automatically - no additional proxy configuration needed. See [README.md](./README.md#cors-configuration) for details. 69 + 49 70 ## nginx Deployment 50 71 51 72 ### Host Setup ··· 309 330 310 331 # BearBlog publications (comma-separated id:url pairs) 311 332 BEARBLOG_URLS = "desertthunder:https://desertthunder.bearblog.dev" 333 + 334 + # CORS configuration (optional) 335 + CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321" 336 + CORS_DEV_KEY = "your-secret-dev-key" 312 337 ``` 338 + 339 + ### CORS Configuration 340 + 341 + The Worker supports CORS to allow cross-origin requests from your web applications. 342 + 343 + #### Environment Variables 344 + 345 + Add to `wrangler.toml` under `[vars]`: 346 + 347 + - **CORS_ALLOWED_ORIGINS**: Comma-separated list of allowed origins 348 + - Supports exact matching: `http://localhost:4321` only allows that exact origin 349 + - Supports same-root-domain: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc. 350 + 351 + - **CORS_DEV_KEY**: Optional development key for local testing 352 + - When set, requests with the `X-Local-Dev-Key` header matching this value bypass origin checking 353 + - Useful for testing from different local ports during development 354 + 355 + #### Example Configuration 356 + 357 + ```toml 358 + [vars] 359 + # Allow requests from your main domain and localhost for development 360 + CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321" 361 + 362 + # Dev key for local Astro development 363 + CORS_DEV_KEY = "local-dev-secret-123" 364 + ``` 365 + 366 + #### Usage from JavaScript 367 + 368 + ```javascript 369 + // Production request from https://desertthunder.dev 370 + fetch('https://pai.desertthunder.dev/api/feed', { 371 + credentials: 'include' 372 + }) 373 + 374 + // Development request from http://localhost:4321 375 + fetch('http://localhost:8787/api/feed', { 376 + headers: { 377 + 'X-Local-Dev-Key': 'local-dev-secret-123' 378 + } 379 + }) 380 + ``` 381 + 382 + #### Same-Root-Domain Support 383 + 384 + When you configure `CORS_ALLOWED_ORIGINS = "https://desertthunder.dev"`: 385 + 386 + - ✓ `https://desertthunder.dev` (exact match) 387 + - ✓ `https://pai.desertthunder.dev` (subdomain) 388 + - ✓ `https://api.desertthunder.dev` (subdomain) 389 + - ✗ `https://evil.dev` (different root domain) 390 + 391 + This allows you to deploy the Worker to `pai.desertthunder.dev` and access it from your main site at `desertthunder.dev` without explicitly listing every subdomain. 313 392 314 393 ### API Endpoints 315 394
+76 -1
README.md
··· 69 69 70 70 See [config.example.toml](./config.example.toml) for a complete example with all available options. 71 71 72 + <details> 73 + <summary> 74 + CORS Configuration 75 + </summary> 76 + 77 + Both the HTTP server and Cloudflare Worker support CORS configuration to allow cross-origin requests from your web applications. 78 + 79 + ### HTTP Server (config.toml) 80 + 81 + Add a `[cors]` section to your config file: 82 + 83 + ```toml 84 + [cors] 85 + allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"] 86 + dev_key = "your-secret-dev-key" 87 + ``` 88 + 89 + Configuration options: 90 + 91 + - **allowed_origins**: List of allowed origins. Supports: 92 + - Exact match: `http://localhost:4321` only allows that exact origin 93 + - Same-root-domain: `https://desertthunder.dev` also allows `https://pai.desertthunder.dev`, `https://api.desertthunder.dev`, etc. 94 + - **dev_key**: Optional development key for local testing. 95 + When set, requests with the `X-Local-Dev-Key` header matching this value are allowed regardless of origin. 96 + 97 + ### Cloudflare Worker (Environment Variables) 98 + 99 + Configure CORS via environment variables in `wrangler.toml`: 100 + 101 + ```toml 102 + [vars] 103 + CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321" 104 + CORS_DEV_KEY = "your-secret-dev-key" 105 + ``` 106 + 107 + - **CORS_ALLOWED_ORIGINS**: Comma-separated list of allowed origins 108 + - **CORS_DEV_KEY**: Optional development key (same behavior as HTTP server) 109 + 110 + #### Local Development with X-LOCAL-DEV-KEY 111 + 112 + For local development from Astro or other frameworks: 113 + 114 + 1. Add a `dev_key` to your CORS config: 115 + 116 + ```toml 117 + [cors] 118 + allowed_origins = ["http://localhost:4321"] 119 + dev_key = "local-dev-secret-123" 120 + ``` 121 + 122 + 2. Include the header in your API requests: 123 + 124 + ```javascript 125 + fetch('http://localhost:8080/api/feed', { 126 + headers: { 127 + 'X-Local-Dev-Key': 'local-dev-secret-123' 128 + } 129 + }) 130 + ``` 131 + 132 + The dev key header bypasses origin checking, useful for testing from different local ports or during development. 133 + 134 + #### Same-Root-Domain Support 135 + 136 + If you configure `allowed_origins = ["https://desertthunder.dev"]`, requests from: 137 + 138 + - `https://desertthunder.dev` ✓ (exact match) 139 + - `https://pai.desertthunder.dev` ✓ (subdomain of allowed root) 140 + - `https://api.desertthunder.dev` ✓ (subdomain of allowed root) 141 + - `https://evil.dev` ✗ (different root domain) 142 + 143 + This allows you to deploy the API at `pai.desertthunder.dev` and access it from your main site at `desertthunder.dev` without explicitly listing every subdomain. 144 + 145 + </details> 146 + 72 147 ## Documentation 73 148 74 149 - CLI synopsis: `pai -h`, `pai <command> -h`, or `pai man` for the generated `pai(1)` page. 75 - - `pai man --install [--install-dir DIR]` copies `pai.1` into a MANPATH directory (defaults to `~/.local/share/man/man1`) so `man pai` works like any other UNIX tool. 150 + - `pai man --install [--install-dir DIR]` copies `pai.1` into a MANPATH directory (defaults to `~/.local/share/man/man1`) 76 151 - Database schema and config reference: [config.example.toml](./config.example.toml). 77 152 - Deployment topologies: [DEPLOYMENT.md](./DEPLOYMENT.md). 78 153
+3 -1
cli/src/main.rs
··· 132 132 133 133 fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 134 134 let db_path = paths::resolve_db_path(db_path)?; 135 - pai_server::serve(db_path, &address) 135 + let config_path = paths::resolve_config_dir(None)?.join("config.toml"); 136 + let config = if config_path.exists() { Config::from_file(&config_path)? } else { Config::default() }; 137 + pai_server::serve(config, db_path, &address) 136 138 } 137 139 138 140 fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> {
+11
config.example.toml
··· 19 19 d1_binding = "DB" 20 20 database_name = "personal_activity_db" 21 21 22 + # CORS configuration for HTTP server (optional) 23 + [cors] 24 + # List of allowed origins for cross-origin requests 25 + # Supports exact match and same-root-domain matching 26 + # Example: "https://desertthunder.dev" allows pai.desertthunder.dev, api.desertthunder.dev, etc. 27 + allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"] 28 + 29 + # Optional development key for local testing 30 + # When set, requests with X-Local-Dev-Key header matching this value are allowed 31 + dev_key = "your-secret-dev-key-change-this" 32 + 22 33 # Substack RSS feed source 23 34 [sources.substack] 24 35 enabled = true
+199
core/src/lib.rs
··· 193 193 pub bearblog: Vec<BearBlogConfig>, 194 194 } 195 195 196 + /// CORS configuration for the HTTP server and Worker 197 + /// 198 + /// Supports same-root-domain CORS (e.g., pai.desertthunder.dev from desertthunder.dev) 199 + /// and local development with a dev key header. 200 + #[derive(Debug, Clone, Deserialize, Serialize, Default)] 201 + pub struct CorsConfig { 202 + /// List of allowed origins (exact match or same-root-domain) 203 + /// Example: ["https://desertthunder.dev", "http://localhost:4321"] 204 + #[serde(default)] 205 + pub allowed_origins: Vec<String>, 206 + 207 + /// Optional development key for local development 208 + /// When set, requests with X-LOCAL-DEV-KEY header matching this value are allowed 209 + pub dev_key: Option<String>, 210 + } 211 + 212 + impl CorsConfig { 213 + /// Check if an origin is allowed based on exact match or same-root-domain logic. 214 + /// 215 + /// Same-root-domain means extracting the root domain (last two parts) from both 216 + /// the origin and allowed origins, and checking for a match. 217 + /// 218 + /// Examples: 219 + /// - https://pai.desertthunder.dev is allowed if https://desertthunder.dev is in allowed_origins 220 + /// - http://localhost:4321 requires exact match 221 + pub fn is_origin_allowed(&self, origin: &str) -> bool { 222 + if self.allowed_origins.is_empty() { 223 + return false; 224 + } 225 + 226 + let origin_domain = extract_domain(origin); 227 + 228 + for allowed in &self.allowed_origins { 229 + if origin == allowed { 230 + return true; 231 + } 232 + 233 + let allowed_domain = extract_domain(allowed); 234 + if let (Some(origin_root), Some(allowed_root)) = ( 235 + extract_root_domain(&origin_domain), 236 + extract_root_domain(&allowed_domain), 237 + ) { 238 + if origin_root == allowed_root { 239 + return true; 240 + } 241 + } 242 + } 243 + 244 + false 245 + } 246 + 247 + /// Validate if a dev key matches the configured dev key 248 + pub fn is_dev_key_valid(&self, key: Option<&str>) -> bool { 249 + match (&self.dev_key, key) { 250 + (Some(config_key), Some(request_key)) => config_key == request_key, 251 + _ => false, 252 + } 253 + } 254 + } 255 + 256 + /// Extract domain from URL (removes protocol and path) 257 + fn extract_domain(url: &str) -> String { 258 + url.trim_start_matches("https://") 259 + .trim_start_matches("http://") 260 + .split('/') 261 + .next() 262 + .unwrap_or("") 263 + .split(':') 264 + .next() 265 + .unwrap_or("") 266 + .to_string() 267 + } 268 + 269 + /// Extract root domain (last two parts of domain) 270 + /// Example: "pai.desertthunder.dev" -> Some("desertthunder.dev") 271 + /// Example: "localhost" -> None (single part) 272 + fn extract_root_domain(domain: &str) -> Option<String> { 273 + let parts: Vec<&str> = domain.split('.').collect(); 274 + if parts.len() >= 2 { 275 + Some(format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])) 276 + } else { 277 + None 278 + } 279 + } 280 + 196 281 /// Configuration for all sources 197 282 #[derive(Debug, Clone, Deserialize, Serialize, Default)] 198 283 pub struct Config { ··· 202 287 pub deployment: DeploymentConfig, 203 288 #[serde(default)] 204 289 pub sources: SourcesConfig, 290 + #[serde(default)] 291 + pub cors: CorsConfig, 205 292 } 206 293 207 294 impl Config { ··· 458 545 let config = Config::from_str(toml).unwrap(); 459 546 let substack = config.sources.substack.as_ref().unwrap(); 460 547 assert!(!substack.enabled); 548 + } 549 + 550 + #[test] 551 + fn cors_config_exact_match() { 552 + let cors = CorsConfig { 553 + allowed_origins: vec![ 554 + "https://desertthunder.dev".to_string(), 555 + "http://localhost:4321".to_string(), 556 + ], 557 + dev_key: None, 558 + }; 559 + assert!(cors.is_origin_allowed("https://desertthunder.dev")); 560 + assert!(cors.is_origin_allowed("http://localhost:4321")); 561 + assert!(!cors.is_origin_allowed("https://evil.com")); 562 + } 563 + 564 + #[test] 565 + fn cors_config_same_root_domain() { 566 + let cors = CorsConfig { allowed_origins: vec!["https://desertthunder.dev".to_string()], dev_key: None }; 567 + assert!(cors.is_origin_allowed("https://pai.desertthunder.dev")); 568 + assert!(cors.is_origin_allowed("https://api.desertthunder.dev")); 569 + assert!(cors.is_origin_allowed("https://desertthunder.dev")); 570 + assert!(!cors.is_origin_allowed("https://evil.dev")); 571 + } 572 + 573 + #[test] 574 + fn cors_config_localhost_requires_exact_match() { 575 + let cors = CorsConfig { allowed_origins: vec!["http://localhost:4321".to_string()], dev_key: None }; 576 + assert!(cors.is_origin_allowed("http://localhost:4321")); 577 + assert!(!cors.is_origin_allowed("http://localhost:3000")); 578 + } 579 + 580 + #[test] 581 + fn cors_config_empty_origins_denies_all() { 582 + let cors = CorsConfig { allowed_origins: vec![], dev_key: None }; 583 + assert!(!cors.is_origin_allowed("https://desertthunder.dev")); 584 + assert!(!cors.is_origin_allowed("http://localhost:4321")); 585 + } 586 + 587 + #[test] 588 + fn cors_config_dev_key_valid() { 589 + let cors = CorsConfig { allowed_origins: vec![], dev_key: Some("secret-dev-key".to_string()) }; 590 + assert!(cors.is_dev_key_valid(Some("secret-dev-key"))); 591 + assert!(!cors.is_dev_key_valid(Some("wrong-key"))); 592 + assert!(!cors.is_dev_key_valid(None)); 593 + } 594 + 595 + #[test] 596 + fn cors_config_dev_key_none() { 597 + let cors = CorsConfig { allowed_origins: vec![], dev_key: None }; 598 + assert!(!cors.is_dev_key_valid(Some("any-key"))); 599 + assert!(!cors.is_dev_key_valid(None)); 600 + } 601 + 602 + #[test] 603 + fn extract_domain_https() { 604 + assert_eq!( 605 + super::extract_domain("https://desertthunder.dev/path"), 606 + "desertthunder.dev" 607 + ); 608 + assert_eq!( 609 + super::extract_domain("https://pai.desertthunder.dev"), 610 + "pai.desertthunder.dev" 611 + ); 612 + } 613 + 614 + #[test] 615 + fn extract_domain_http() { 616 + assert_eq!(super::extract_domain("http://localhost:4321/api"), "localhost"); 617 + assert_eq!(super::extract_domain("http://example.com"), "example.com"); 618 + } 619 + 620 + #[test] 621 + fn extract_root_domain_multi_level() { 622 + assert_eq!( 623 + super::extract_root_domain("pai.desertthunder.dev"), 624 + Some("desertthunder.dev".to_string()) 625 + ); 626 + assert_eq!( 627 + super::extract_root_domain("api.example.com"), 628 + Some("example.com".to_string()) 629 + ); 630 + assert_eq!( 631 + super::extract_root_domain("a.b.c.example.org"), 632 + Some("example.org".to_string()) 633 + ); 634 + } 635 + 636 + #[test] 637 + fn extract_root_domain_single_part() { 638 + assert_eq!(super::extract_root_domain("localhost"), None); 639 + } 640 + 641 + #[test] 642 + fn extract_root_domain_two_parts() { 643 + assert_eq!( 644 + super::extract_root_domain("example.com"), 645 + Some("example.com".to_string()) 646 + ); 647 + } 648 + 649 + #[test] 650 + fn config_parse_cors() { 651 + let toml = r#" 652 + [cors] 653 + allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"] 654 + dev_key = "my-dev-key" 655 + "#; 656 + let config = Config::from_str(toml).unwrap(); 657 + assert_eq!(config.cors.allowed_origins.len(), 2); 658 + assert_eq!(config.cors.allowed_origins[0], "https://desertthunder.dev"); 659 + assert_eq!(config.cors.dev_key, Some("my-dev-key".to_string())); 461 660 } 462 661 }
+3 -1
server/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 pai-core = { path = "../core" } 8 - axum = "0.7" 8 + axum = "0.8" 9 9 tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } 10 10 rusqlite = { version = "0.37", features = ["bundled"] } 11 11 serde = { version = "1.0", features = ["derive"] } ··· 13 13 owo-colors = "4.1" 14 14 chrono = "0.4" 15 15 rss = "2.0" 16 + tower = "0.5" 17 + tower-http = { version = "0.6", features = ["cors"] } 16 18 17 19 [dev-dependencies] 18 20 tempfile = "3.13"
+92 -11
server/src/server.rs
··· 1 1 use crate::storage::SqliteStorage; 2 2 3 3 use axum::{ 4 - extract::{Path, Query, State}, 5 - http::{header, StatusCode}, 4 + extract::{Path, Query, Request, State}, 5 + http::{header, HeaderValue, Method, StatusCode}, 6 + middleware::{self, Next}, 6 7 response::{IntoResponse, Response}, 7 8 routing::get, 8 9 Json, Router, 9 10 }; 10 11 use chrono::DateTime; 11 12 use owo_colors::OwoColorize; 12 - use pai_core::{Item, ListFilter, PaiError, SourceKind}; 13 + use pai_core::{Config, CorsConfig, Item, ListFilter, PaiError, SourceKind}; 13 14 use rss::{Channel, ChannelBuilder, ItemBuilder}; 14 15 use serde::{Deserialize, Serialize}; 15 16 use std::{io, net::SocketAddr, path::PathBuf, sync::Arc, time::Instant}; ··· 18 19 const DEFAULT_LIMIT: usize = 20; 19 20 const VERSION: &str = env!("CARGO_PKG_VERSION"); 20 21 21 - /// Launches the HTTP server using the provided SQLite database path and address. 22 - pub fn serve(db_path: PathBuf, address: &str) -> Result<(), PaiError> { 22 + /// Launches the HTTP server using the provided config and address. 23 + pub fn serve(config: Config, db_path: PathBuf, address: &str) -> Result<(), PaiError> { 23 24 let addr: SocketAddr = address 24 25 .parse() 25 26 .map_err(|e| PaiError::Config(format!("Invalid listen address '{address}': {e}")))?; ··· 29 30 .build() 30 31 .map_err(PaiError::Io)?; 31 32 32 - runtime.block_on(async move { run_server(db_path, addr).await }) 33 + runtime.block_on(async move { run_server(config, db_path, addr).await }) 33 34 } 34 35 35 - async fn run_server(db_path: PathBuf, addr: SocketAddr) -> Result<(), PaiError> { 36 + async fn run_server(config: Config, db_path: PathBuf, addr: SocketAddr) -> Result<(), PaiError> { 36 37 let storage = SqliteStorage::new(&db_path)?; 37 38 storage.verify_schema()?; 38 39 drop(storage); 39 40 40 - let state = AppState { db_path: Arc::new(db_path), start_time: Instant::now() }; 41 + let state = 42 + AppState { db_path: Arc::new(db_path), start_time: Instant::now(), cors_config: Arc::new(config.cors.clone()) }; 41 43 42 - let app = Router::new() 44 + let mut app = Router::new() 43 45 .route("/api/feed", get(feed_handler)) 44 46 .route("/api/item/:id", get(item_handler)) 45 47 .route("/status", get(status_handler)) 46 48 .route("/rss.xml", get(rss_handler)) 47 - .with_state(state); 49 + .with_state(state.clone()); 50 + 51 + if !config.cors.allowed_origins.is_empty() || config.cors.dev_key.is_some() { 52 + app = app.layer(middleware::from_fn_with_state(state.clone(), cors_middleware)); 53 + } 48 54 49 55 let listener = TcpListener::bind(addr).await.map_err(PaiError::Io)?; 50 56 let local_addr = listener.local_addr().map_err(PaiError::Io)?; ··· 56 62 .map_err(|e| io::Error::other(e).into()) 57 63 } 58 64 65 + /// CORS middleware that validates origins and dev keys 66 + async fn cors_middleware(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, StatusCode> { 67 + let origin = request 68 + .headers() 69 + .get(header::ORIGIN) 70 + .and_then(|v| v.to_str().ok()) 71 + .map(|s| s.to_string()); 72 + let dev_key = request 73 + .headers() 74 + .get("x-local-dev-key") 75 + .and_then(|v| v.to_str().ok()) 76 + .map(|s| s.to_string()); 77 + let method = request.method().clone(); 78 + 79 + let is_authorized = if let Some(ref key) = dev_key { 80 + state.cors_config.is_dev_key_valid(Some(key)) 81 + } else if let Some(ref origin_str) = origin { 82 + state.cors_config.is_origin_allowed(origin_str) 83 + } else { 84 + true 85 + }; 86 + 87 + if method == Method::OPTIONS { 88 + if !is_authorized { 89 + return Err(StatusCode::FORBIDDEN); 90 + } 91 + 92 + let mut response = Response::new(String::new().into()); 93 + if let Some(ref origin_str) = origin { 94 + response.headers_mut().insert( 95 + header::ACCESS_CONTROL_ALLOW_ORIGIN, 96 + HeaderValue::from_str(origin_str).unwrap_or(HeaderValue::from_static("*")), 97 + ); 98 + } 99 + response.headers_mut().insert( 100 + header::ACCESS_CONTROL_ALLOW_METHODS, 101 + HeaderValue::from_static("GET, POST, OPTIONS"), 102 + ); 103 + response.headers_mut().insert( 104 + header::ACCESS_CONTROL_ALLOW_HEADERS, 105 + HeaderValue::from_static("Content-Type, X-Local-Dev-Key"), 106 + ); 107 + response 108 + .headers_mut() 109 + .insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("3600")); 110 + return Ok(response); 111 + } 112 + 113 + if origin.is_some() && !is_authorized { 114 + return Err(StatusCode::FORBIDDEN); 115 + } 116 + 117 + let mut response = next.run(request).await; 118 + 119 + if let Some(ref origin_str) = origin { 120 + if is_authorized { 121 + response.headers_mut().insert( 122 + header::ACCESS_CONTROL_ALLOW_ORIGIN, 123 + HeaderValue::from_str(origin_str).unwrap_or(HeaderValue::from_static("*")), 124 + ); 125 + response.headers_mut().insert( 126 + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, 127 + HeaderValue::from_static("true"), 128 + ); 129 + } 130 + } 131 + 132 + Ok(response) 133 + } 134 + 59 135 #[derive(Clone)] 60 136 struct AppState { 61 137 db_path: Arc<PathBuf>, 62 138 start_time: Instant, 139 + cors_config: Arc<CorsConfig>, 63 140 } 64 141 65 142 impl AppState { ··· 352 429 fn status_snapshot_reports_counts() { 353 430 let dir = tempdir().unwrap(); 354 431 let db_path = dir.path().join("status.db"); 355 - let state = AppState { db_path: Arc::new(db_path), start_time: Instant::now() }; 432 + let state = AppState { 433 + db_path: Arc::new(db_path), 434 + start_time: Instant::now(), 435 + cors_config: Arc::new(pai_core::CorsConfig::default()), 436 + }; 356 437 357 438 let storage = state.open_storage().unwrap(); 358 439 let now = Utc::now().to_rfc3339();
+70 -3
worker/src/lib.rs
··· 1 - use pai_core::{Item, ListFilter, SourceKind}; 1 + use pai_core::{CorsConfig, Item, ListFilter, SourceKind}; 2 2 use serde::{Deserialize, Serialize}; 3 3 use wasm_bindgen::JsValue; 4 4 use worker::*; ··· 106 106 107 107 #[event(fetch)] 108 108 async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> { 109 + let cors_config = load_cors_config(&env); 110 + 111 + if req.method() == Method::Options { 112 + return handle_preflight(&req, &cors_config); 113 + } 114 + 115 + if !is_cors_authorized(&req, &cors_config) { 116 + return Response::error("Forbidden", 403); 117 + } 118 + 119 + let origin = req.headers().get("Origin").ok().flatten(); 120 + 109 121 let router = Router::new(); 110 - router 122 + let mut response = router 111 123 .get_async("/", |req, _ctx| async move { 112 124 let url = req 113 125 .url() ··· 181 193 Response::from_json(&status) 182 194 }) 183 195 .run(req, env) 184 - .await 196 + .await?; 197 + 198 + if let Some(origin_str) = origin { 199 + response.headers_mut().set("Access-Control-Allow-Origin", &origin_str)?; 200 + response.headers_mut().set("Access-Control-Allow-Credentials", "true")?; 201 + } 202 + 203 + Ok(response) 185 204 } 186 205 187 206 #[event(scheduled)] ··· 360 379 }; 361 380 362 381 Ok(SyncConfig { substack, bluesky, leaflet, bearblog }) 382 + } 383 + 384 + /// Load CORS configuration from environment variables 385 + fn load_cors_config(env: &Env) -> CorsConfig { 386 + let allowed_origins = env 387 + .var("CORS_ALLOWED_ORIGINS") 388 + .ok() 389 + .map(|origins| origins.to_string().split(',').map(|s| s.trim().to_string()).collect()) 390 + .unwrap_or_default(); 391 + 392 + let dev_key = env.var("CORS_DEV_KEY").ok().map(|k| k.to_string()); 393 + 394 + CorsConfig { allowed_origins, dev_key } 395 + } 396 + 397 + /// Check if request is authorized for CORS 398 + fn is_cors_authorized(req: &Request, cors_config: &CorsConfig) -> bool { 399 + if let Ok(Some(key)) = req.headers().get("X-Local-Dev-Key") { 400 + if cors_config.is_dev_key_valid(Some(&key)) { 401 + return true; 402 + } 403 + } 404 + 405 + if let Ok(Some(origin_str)) = req.headers().get("Origin") { 406 + return cors_config.is_origin_allowed(&origin_str); 407 + } 408 + 409 + true 410 + } 411 + 412 + /// Handle preflight OPTIONS requests 413 + fn handle_preflight(req: &Request, cors_config: &CorsConfig) -> Result<Response> { 414 + if !is_cors_authorized(req, cors_config) { 415 + return Response::error("Forbidden", 403); 416 + } 417 + 418 + let mut response = Response::empty()?; 419 + let response_headers = response.headers_mut(); 420 + 421 + if let Ok(Some(origin)) = req.headers().get("Origin") { 422 + response_headers.set("Access-Control-Allow-Origin", &origin)?; 423 + } 424 + 425 + response_headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")?; 426 + response_headers.set("Access-Control-Allow-Headers", "Content-Type, X-Local-Dev-Key")?; 427 + response_headers.set("Access-Control-Max-Age", "3600")?; 428 + 429 + Ok(response) 363 430 } 364 431 365 432 async fn sync_substack(config: &SubstackConfig, db: &D1Database) -> Result<usize> {
+10
worker/wrangler.example.toml
··· 36 36 # Format: "id1:https://blog1.bearblog.dev,id2:https://blog2.bearblog.dev" 37 37 BEARBLOG_URLS = "desertthunder:https://desertthunder.bearblog.dev" 38 38 39 + # CORS configuration (optional) 40 + # Comma-separated list of allowed origins for cross-origin requests 41 + # Supports exact match and same-root-domain matching 42 + # Example: "https://desertthunder.dev" allows pai.desertthunder.dev, api.desertthunder.dev, etc. 43 + CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321" 44 + 45 + # Optional development key for local testing 46 + # When set, requests with X-Local-Dev-Key header matching this value are allowed 47 + CORS_DEV_KEY = "your-secret-dev-key-change-this" 48 + 39 49 # Optional: Logging level 40 50 # LOG_LEVEL = "info" 41 51