slack status without the slack status.zzstoatzz.io/
quickslice

Merge pull request #49 from zzstoatzz/feature/webhook-settings

feat(webhooks): add webhook settings UI + API and signed delivery on status changes

authored by nate nowack and committed by GitHub a95a478f 4002b111

+10
Cargo.lock
··· 1585 1585 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1586 1586 1587 1587 [[package]] 1588 + name = "hex" 1589 + version = "0.4.3" 1590 + source = "registry+https://github.com/rust-lang/crates.io-index" 1591 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1592 + 1593 + [[package]] 1588 1594 name = "hickory-proto" 1589 1595 version = "0.24.4" 1590 1596 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2358 2364 "dotenv", 2359 2365 "env_logger", 2360 2366 "futures-util", 2367 + "hex", 2361 2368 "hickory-resolver", 2369 + "hmac", 2362 2370 "log", 2363 2371 "once_cell", 2364 2372 "rand 0.8.5", ··· 2366 2374 "rocketman", 2367 2375 "serde", 2368 2376 "serde_json", 2377 + "sha2", 2369 2378 "thiserror", 2370 2379 "tokio", 2380 + "url", 2371 2381 ] 2372 2382 2373 2383 [[package]]
+4
Cargo.toml
··· 32 32 rand = "0.8" 33 33 reqwest = { version = "0.12", features = ["json"] } 34 34 once_cell = "1.19" 35 + hmac = "0.12" 36 + sha2 = "0.10" 37 + hex = "0.4" 38 + url = "2.5" 35 39 36 40 [build-dependencies] 37 41 askama = "0.13"
+31 -22
src/api/mod.rs
··· 1 1 pub mod auth; 2 2 pub mod preferences; 3 - pub mod status; 3 + pub mod status_read; 4 + pub mod status_util; 5 + pub mod status_write; 6 + pub mod webhooks; 4 7 8 + pub use crate::api::status_util::HandleResolver; 5 9 pub use auth::OAuthClientType; 6 - pub use status::HandleResolver; 7 10 8 11 use actix_web::web; 9 12 ··· 16 19 .service(auth::login) 17 20 .service(auth::logout) 18 21 .service(auth::login_post) 19 - // Status page routes 20 - .service(status::home) 21 - .service(status::user_status_page) 22 - .service(status::feed) 23 - // Status JSON API routes 24 - .service(status::owner_status_json) 25 - .service(status::user_status_json) 26 - .service(status::status_json) 27 - .service(status::api_feed) 28 - // Emoji API routes 29 - .service(status::get_frequent_emojis) 30 - .service(status::get_custom_emojis) 31 - .service(status::upload_emoji) 32 - .service(status::get_following) 33 - // Status management routes 34 - .service(status::status) 35 - .service(status::clear_status) 36 - .service(status::delete_status) 37 - .service(status::hide_status) 22 + // Status page routes (read) 23 + .service(status_read::home) 24 + .service(status_read::user_status_page) 25 + .service(status_read::feed) 26 + // Status JSON API routes (read) 27 + .service(status_read::owner_status_json) 28 + .service(status_read::user_status_json) 29 + .service(status_read::status_json) 30 + .service(status_read::api_feed) 31 + // Emoji + following routes 32 + .service(status_read::get_frequent_emojis) 33 + .service(status_read::get_custom_emojis) 34 + .service(status_write::upload_emoji) 35 + .service(status_read::get_following) 36 + // Status management routes (write) 37 + .service(status_write::status) 38 + .service(status_write::clear_status) 39 + .service(status_write::delete_status) 40 + .service(status_write::hide_status) 38 41 // Preferences routes 39 42 .service(preferences::get_preferences) 40 - .service(preferences::save_preferences); 43 + .service(preferences::save_preferences) 44 + // Webhook routes 45 + .service(webhooks::list_webhooks) 46 + .service(webhooks::create_webhook) 47 + .service(webhooks::update_webhook) 48 + .service(webhooks::rotate_secret) 49 + .service(webhooks::delete_webhook); 41 50 }
-1436
src/api/status.rs
··· 1 - use crate::config::Config; 2 - use crate::emoji::is_builtin_slug; 3 - use crate::resolver::HickoryDnsTxtResolver; 4 - use crate::{ 5 - api::auth::OAuthClientType, 6 - config, 7 - db::{self, StatusFromDb}, 8 - dev_utils, 9 - error_handler::AppError, 10 - lexicons::record::KnownRecord, 11 - rate_limiter::RateLimiter, 12 - templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate}, 13 - }; 14 - use actix_multipart::Multipart; 15 - use actix_session::Session; 16 - use actix_web::{ 17 - HttpRequest, HttpResponse, Responder, Result, get, post, 18 - web::{self, Redirect}, 19 - }; 20 - use askama::Template; 21 - use async_sqlite::{Pool, rusqlite}; 22 - use atrium_api::{ 23 - agent::Agent, 24 - types::string::{Datetime, Did}, 25 - }; 26 - use atrium_common::resolver::Resolver; 27 - use atrium_identity::{ 28 - did::CommonDidResolver, 29 - handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 30 - }; 31 - use atrium_oauth::DefaultHttpClient; 32 - use futures_util::TryStreamExt as _; 33 - use serde::{Deserialize, Serialize}; 34 - use std::{collections::HashMap, sync::Arc}; 35 - 36 - /// HandleResolver to make it easier to access the OAuthClient in web requests 37 - pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 38 - 39 - /// Admin DID for moderation 40 - const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 41 - 42 - /// Check if a DID is the admin 43 - fn is_admin(did: &str) -> bool { 44 - did == ADMIN_DID 45 - } 46 - 47 - /// The post body for changing your status 48 - #[derive(Serialize, Deserialize, Clone)] 49 - pub struct StatusForm { 50 - pub status: String, 51 - pub text: Option<String>, 52 - pub expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 53 - } 54 - 55 - /// The post body for deleting a specific status 56 - #[derive(Serialize, Deserialize)] 57 - pub struct DeleteRequest { 58 - pub uri: String, 59 - } 60 - 61 - /// Hide/unhide a status (admin only) 62 - #[derive(Deserialize)] 63 - pub struct HideStatusRequest { 64 - pub uri: String, 65 - pub hidden: bool, 66 - } 67 - 68 - /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 69 - fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 70 - if duration_str.is_empty() { 71 - return None; 72 - } 73 - 74 - let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 75 - let num: i64 = num_str.parse().ok()?; 76 - 77 - match unit { 78 - "m" => Some(chrono::Duration::minutes(num)), 79 - "h" => Some(chrono::Duration::hours(num)), 80 - "d" => Some(chrono::Duration::days(num)), 81 - "w" => Some(chrono::Duration::weeks(num)), 82 - _ => None, 83 - } 84 - } 85 - 86 - /// Homepage - shows logged-in user's status, or owner's status if not logged in 87 - #[get("/")] 88 - pub async fn home( 89 - session: Session, 90 - _oauth_client: web::Data<OAuthClientType>, 91 - db_pool: web::Data<Arc<Pool>>, 92 - handle_resolver: web::Data<HandleResolver>, 93 - ) -> Result<impl Responder> { 94 - // Default owner of the domain 95 - const OWNER_HANDLE: &str = "zzstoatzz.io"; 96 - 97 - // Check if user is logged in 98 - match session.get::<String>("did").unwrap_or(None) { 99 - Some(did_string) => { 100 - // User is logged in - show their status page 101 - log::debug!("Home: User is logged in with DID: {}", did_string); 102 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 103 - 104 - // Get their handle 105 - let handle = match handle_resolver.resolve(&did).await { 106 - Ok(did_doc) => did_doc 107 - .also_known_as 108 - .and_then(|aka| aka.first().map(|h| h.replace("at://", ""))) 109 - .unwrap_or_else(|| did_string.clone()), 110 - Err(_) => did_string.clone(), 111 - }; 112 - 113 - // Get user's status 114 - let current_status = StatusFromDb::my_status(&db_pool, &did) 115 - .await 116 - .unwrap_or(None) 117 - .and_then(|s| { 118 - // Check if status is expired 119 - if let Some(expires_at) = s.expires_at { 120 - if chrono::Utc::now() > expires_at { 121 - return None; // Status expired 122 - } 123 - } 124 - Some(s) 125 - }); 126 - 127 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 128 - .await 129 - .unwrap_or_else(|err| { 130 - log::error!("Error loading status history: {err}"); 131 - vec![] 132 - }); 133 - 134 - let is_admin_flag = is_admin(did.as_str()); 135 - let html = StatusTemplate { 136 - title: "your status", 137 - handle, 138 - current_status, 139 - history, 140 - is_owner: true, // They're viewing their own status 141 - is_admin: is_admin_flag, 142 - } 143 - .render() 144 - .expect("template should be valid"); 145 - 146 - Ok(web::Html::new(html)) 147 - } 148 - None => { 149 - // Not logged in - show owner's status 150 - // Resolve owner handle to DID 151 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 152 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 153 - http_client: Arc::new(DefaultHttpClient::default()), 154 - }); 155 - 156 - let owner_handle = 157 - atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 158 - let owner_did = if let Some(handle) = owner_handle { 159 - atproto_handle_resolver.resolve(&handle).await.ok() 160 - } else { 161 - None 162 - }; 163 - 164 - let current_status = if let Some(ref did) = owner_did { 165 - StatusFromDb::my_status(&db_pool, did) 166 - .await 167 - .unwrap_or(None) 168 - .and_then(|s| { 169 - // Check if status is expired 170 - if let Some(expires_at) = s.expires_at { 171 - if chrono::Utc::now() > expires_at { 172 - return None; // Status expired 173 - } 174 - } 175 - Some(s) 176 - }) 177 - } else { 178 - None 179 - }; 180 - 181 - let history = if let Some(ref did) = owner_did { 182 - StatusFromDb::load_user_statuses(&db_pool, did, 10) 183 - .await 184 - .unwrap_or_else(|err| { 185 - log::error!("Error loading status history: {err}"); 186 - vec![] 187 - }) 188 - } else { 189 - vec![] 190 - }; 191 - 192 - let html = StatusTemplate { 193 - title: "nate's status", 194 - handle: OWNER_HANDLE.to_string(), 195 - current_status, 196 - history, 197 - is_owner: false, // Visitor viewing owner's status 198 - is_admin: false, 199 - } 200 - .render() 201 - .expect("template should be valid"); 202 - 203 - Ok(web::Html::new(html)) 204 - } 205 - } 206 - } 207 - 208 - /// View a specific user's status page by handle 209 - #[get("/@{handle}")] 210 - pub async fn user_status_page( 211 - handle: web::Path<String>, 212 - session: Session, 213 - db_pool: web::Data<Arc<Pool>>, 214 - _handle_resolver: web::Data<HandleResolver>, 215 - ) -> Result<impl Responder> { 216 - let handle = handle.into_inner(); 217 - 218 - // Resolve handle to DID using ATProto handle resolution 219 - // First we need to create a handle resolver 220 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 221 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 222 - http_client: Arc::new(DefaultHttpClient::default()), 223 - }); 224 - 225 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 226 - let did = match handle_obj { 227 - Some(h) => match atproto_handle_resolver.resolve(&h).await { 228 - Ok(did) => did, 229 - Err(_) => { 230 - // Could not resolve handle 231 - let html = ErrorTemplate { 232 - title: "User not found", 233 - error: &format!("Could not find user @{}. This handle may not exist or may not be using the ATProto network.", handle), 234 - } 235 - .render() 236 - .expect("template should be valid"); 237 - return Ok(web::Html::new(html)); 238 - } 239 - }, 240 - None => { 241 - // Invalid handle format 242 - let html = ErrorTemplate { 243 - title: "Invalid handle", 244 - error: &format!( 245 - "'{}' is not a valid handle format. Handles should be like 'alice.bsky.social'", 246 - handle 247 - ), 248 - } 249 - .render() 250 - .expect("template should be valid"); 251 - return Ok(web::Html::new(html)); 252 - } 253 - }; 254 - 255 - // Check if logged in user is viewing their own page 256 - let is_owner = match session.get::<String>("did").unwrap_or(None) { 257 - Some(session_did) => session_did == did.to_string(), 258 - None => false, 259 - }; 260 - 261 - // Get user's status 262 - let current_status = StatusFromDb::my_status(&db_pool, &did) 263 - .await 264 - .unwrap_or(None) 265 - .and_then(|s| { 266 - // Check if status is expired 267 - if let Some(expires_at) = s.expires_at { 268 - if chrono::Utc::now() > expires_at { 269 - return None; // Status expired 270 - } 271 - } 272 - Some(s) 273 - }); 274 - 275 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 276 - .await 277 - .unwrap_or_else(|err| { 278 - log::error!("Error loading status history: {err}"); 279 - vec![] 280 - }); 281 - 282 - let is_admin_flag = match session.get::<String>("did").unwrap_or(None) { 283 - Some(d) => is_admin(&d), 284 - None => false, 285 - }; 286 - let html = StatusTemplate { 287 - title: &format!("@{} status", handle), 288 - handle: handle.clone(), 289 - current_status, 290 - history, 291 - is_owner, 292 - is_admin: is_admin_flag, 293 - } 294 - .render() 295 - .expect("template should be valid"); 296 - 297 - Ok(web::Html::new(html)) 298 - } 299 - 300 - /// JSON API for the owner's status (top-level endpoint) 301 - #[get("/json")] 302 - pub async fn owner_status_json( 303 - db_pool: web::Data<Arc<Pool>>, 304 - _handle_resolver: web::Data<HandleResolver>, 305 - ) -> Result<impl Responder> { 306 - // Default owner of the domain 307 - const OWNER_HANDLE: &str = "zzstoatzz.io"; 308 - 309 - // Resolve handle to DID using ATProto handle resolution 310 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 311 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 312 - http_client: Arc::new(DefaultHttpClient::default()), 313 - }); 314 - 315 - let did = match atproto_handle_resolver 316 - .resolve(&OWNER_HANDLE.parse().expect("failed to parse handle")) 317 - .await 318 - { 319 - Ok(d) => Some(d.to_string()), 320 - Err(e) => { 321 - log::error!("Failed to resolve handle {}: {}", OWNER_HANDLE, e); 322 - None 323 - } 324 - }; 325 - 326 - let current_status = if let Some(did) = did { 327 - let did = Did::new(did).expect("failed to parse did"); 328 - StatusFromDb::my_status(&db_pool, &did) 329 - .await 330 - .unwrap_or(None) 331 - .and_then(|s| { 332 - // Check if status is expired 333 - if let Some(expires_at) = s.expires_at { 334 - if chrono::Utc::now() > expires_at { 335 - return None; // Status expired 336 - } 337 - } 338 - Some(s) 339 - }) 340 - } else { 341 - None 342 - }; 343 - 344 - let response = if let Some(status_data) = current_status { 345 - serde_json::json!({ 346 - "handle": OWNER_HANDLE, 347 - "status": "known", 348 - "emoji": status_data.status, 349 - "text": status_data.text, 350 - "since": status_data.started_at.to_rfc3339(), 351 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 352 - }) 353 - } else { 354 - serde_json::json!({ 355 - "handle": OWNER_HANDLE, 356 - "status": "unknown", 357 - "message": "No current status is known" 358 - }) 359 - }; 360 - 361 - Ok(web::Json(response)) 362 - } 363 - 364 - /// JSON API for a specific user's status 365 - #[get("/@{handle}/json")] 366 - pub async fn user_status_json( 367 - handle: web::Path<String>, 368 - db_pool: web::Data<Arc<Pool>>, 369 - _handle_resolver: web::Data<HandleResolver>, 370 - ) -> Result<impl Responder> { 371 - let handle = handle.into_inner(); 372 - 373 - // Resolve handle to DID using ATProto handle resolution 374 - // First we need to create a handle resolver 375 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 376 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 377 - http_client: Arc::new(DefaultHttpClient::default()), 378 - }); 379 - 380 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 381 - let did = match handle_obj { 382 - Some(h) => match atproto_handle_resolver.resolve(&h).await { 383 - Ok(did) => did, 384 - Err(_) => { 385 - return Ok(web::Json(serde_json::json!({ 386 - "status": "unknown", 387 - "message": format!("Could not resolve handle @{}", handle) 388 - }))); 389 - } 390 - }, 391 - None => { 392 - return Ok(web::Json(serde_json::json!({ 393 - "status": "unknown", 394 - "message": format!("Invalid handle format: @{}", handle) 395 - }))); 396 - } 397 - }; 398 - 399 - let current_status = StatusFromDb::my_status(&db_pool, &did) 400 - .await 401 - .unwrap_or(None) 402 - .and_then(|s| { 403 - // Check if status is expired 404 - if let Some(expires_at) = s.expires_at { 405 - if chrono::Utc::now() > expires_at { 406 - return None; // Status expired 407 - } 408 - } 409 - Some(s) 410 - }); 411 - 412 - let response = if let Some(status_data) = current_status { 413 - serde_json::json!({ 414 - "status": "known", 415 - "emoji": status_data.status, 416 - "text": status_data.text, 417 - "since": status_data.started_at.to_rfc3339(), 418 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 419 - }) 420 - } else { 421 - serde_json::json!({ 422 - "status": "unknown", 423 - "message": format!("No current status is known for @{}", handle) 424 - }) 425 - }; 426 - 427 - Ok(web::Json(response)) 428 - } 429 - 430 - /// JSON API endpoint for status - returns current status or "unknown" 431 - #[get("/api/status")] 432 - pub async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 433 - const OWNER_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 434 - 435 - let owner_did = Did::new(OWNER_DID.to_string()).ok(); 436 - let current_status = if let Some(ref did) = owner_did { 437 - StatusFromDb::my_status(&db_pool, did) 438 - .await 439 - .unwrap_or(None) 440 - .and_then(|s| { 441 - // Check if status is expired 442 - if let Some(expires_at) = s.expires_at { 443 - if chrono::Utc::now() > expires_at { 444 - return None; // Status expired 445 - } 446 - } 447 - Some(s) 448 - }) 449 - } else { 450 - None 451 - }; 452 - 453 - let response = if let Some(status_data) = current_status { 454 - serde_json::json!({ 455 - "status": "known", 456 - "emoji": status_data.status, 457 - "text": status_data.text, 458 - "since": status_data.started_at.to_rfc3339(), 459 - "expires": status_data.expires_at.map(|e| e.to_rfc3339()), 460 - }) 461 - } else { 462 - serde_json::json!({ 463 - "status": "unknown", 464 - "message": "No current status is known" 465 - }) 466 - }; 467 - 468 - Ok(web::Json(response)) 469 - } 470 - 471 - /// Feed page - shows all users' statuses 472 - #[get("/feed")] 473 - pub async fn feed( 474 - request: HttpRequest, 475 - session: Session, 476 - oauth_client: web::Data<OAuthClientType>, 477 - db_pool: web::Data<Arc<Pool>>, 478 - handle_resolver: web::Data<HandleResolver>, 479 - config: web::Data<config::Config>, 480 - ) -> Result<impl Responder> { 481 - // This is essentially the old home function 482 - const TITLE: &str = "status feed"; 483 - 484 - // Check if dev mode is active 485 - let query = request.query_string(); 486 - let use_dev_mode = config.dev_mode && dev_utils::is_dev_mode_requested(query); 487 - 488 - let mut statuses = if use_dev_mode { 489 - // Mix dummy data with real data for testing 490 - let mut real_statuses = StatusFromDb::load_latest_statuses(&db_pool) 491 - .await 492 - .unwrap_or_else(|err| { 493 - log::error!("Error loading statuses: {err}"); 494 - vec![] 495 - }); 496 - let dummy_statuses = dev_utils::generate_dummy_statuses(15); 497 - real_statuses.extend(dummy_statuses); 498 - // Resort by started_at 499 - real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 500 - real_statuses 501 - } else { 502 - StatusFromDb::load_latest_statuses(&db_pool) 503 - .await 504 - .unwrap_or_else(|err| { 505 - log::error!("Error loading statuses: {err}"); 506 - vec![] 507 - }) 508 - }; 509 - 510 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 511 - for db_status in &mut statuses { 512 - let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 513 - match quick_resolve_map.get(&authors_did) { 514 - None => {} 515 - Some(found_handle) => { 516 - db_status.handle = Some(found_handle.clone()); 517 - continue; 518 - } 519 - } 520 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 521 - Ok(did_doc) => match did_doc.also_known_as { 522 - None => None, 523 - Some(also_known_as) => match also_known_as.is_empty() { 524 - true => None, 525 - false => { 526 - let full_handle = also_known_as.first().unwrap(); 527 - let handle = full_handle.replace("at://", ""); 528 - quick_resolve_map.insert(authors_did, handle.clone()); 529 - Some(handle) 530 - } 531 - }, 532 - }, 533 - Err(err) => { 534 - log::error!("Error resolving did: {err}"); 535 - None 536 - } 537 - }; 538 - } 539 - 540 - match session.get::<String>("did").unwrap_or(None) { 541 - Some(did_string) => { 542 - log::debug!("Feed: User has session with DID: {}", did_string); 543 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 544 - let _my_status = StatusFromDb::my_status(&db_pool, &did) 545 - .await 546 - .unwrap_or_else(|err| { 547 - log::error!("Error loading my status: {err}"); 548 - None 549 - }); 550 - 551 - log::debug!( 552 - "Feed: Attempting to restore OAuth session for DID: {}", 553 - did_string 554 - ); 555 - match oauth_client.restore(&did).await { 556 - Ok(session) => { 557 - log::debug!("Feed: Successfully restored OAuth session"); 558 - let agent = Agent::new(session); 559 - let profile = agent 560 - .api 561 - .app 562 - .bsky 563 - .actor 564 - .get_profile( 565 - atrium_api::app::bsky::actor::get_profile::ParametersData { 566 - actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 567 - } 568 - .into(), 569 - ) 570 - .await; 571 - 572 - let is_admin = is_admin(did.as_str()); 573 - let html = FeedTemplate { 574 - title: TITLE, 575 - profile: match profile { 576 - Ok(profile) => { 577 - let profile_data = Profile { 578 - did: profile.did.to_string(), 579 - display_name: profile.display_name.clone(), 580 - handle: Some(profile.handle.to_string()), 581 - }; 582 - Some(profile_data) 583 - } 584 - Err(err) => { 585 - log::error!("Error accessing profile: {err}"); 586 - None 587 - } 588 - }, 589 - statuses, 590 - is_admin, 591 - dev_mode: use_dev_mode, 592 - } 593 - .render() 594 - .expect("template should be valid"); 595 - 596 - Ok(web::Html::new(html)) 597 - } 598 - Err(err) => { 599 - // Don't purge the session - OAuth tokens might be expired but user is still logged in 600 - log::warn!("Could not restore OAuth session for feed: {:?}", err); 601 - 602 - // Show feed without profile info instead of error page 603 - let html = FeedTemplate { 604 - title: TITLE, 605 - profile: None, 606 - statuses, 607 - is_admin: is_admin(did.as_str()), 608 - dev_mode: use_dev_mode, 609 - } 610 - .render() 611 - .expect("template should be valid"); 612 - 613 - Ok(web::Html::new(html)) 614 - } 615 - } 616 - } 617 - None => { 618 - let html = FeedTemplate { 619 - title: TITLE, 620 - profile: None, 621 - statuses, 622 - is_admin: false, 623 - dev_mode: use_dev_mode, 624 - } 625 - .render() 626 - .expect("template should be valid"); 627 - 628 - Ok(web::Html::new(html)) 629 - } 630 - } 631 - } 632 - 633 - /// Get paginated statuses for infinite scrolling 634 - #[get("/api/feed")] 635 - pub async fn api_feed( 636 - query: web::Query<HashMap<String, String>>, 637 - db_pool: web::Data<Arc<Pool>>, 638 - handle_resolver: web::Data<HandleResolver>, 639 - config: web::Data<config::Config>, 640 - ) -> Result<impl Responder> { 641 - let offset = query 642 - .get("offset") 643 - .and_then(|s| s.parse::<i32>().ok()) 644 - .unwrap_or(0); 645 - let limit = query 646 - .get("limit") 647 - .and_then(|s| s.parse::<i32>().ok()) 648 - .unwrap_or(20) 649 - .min(50); // Cap at 50 items per request 650 - 651 - // Check if dev mode is requested 652 - let use_dev_mode = config.dev_mode && query.get("dev").is_some_and(|v| v == "true" || v == "1"); 653 - 654 - let mut statuses = if use_dev_mode && offset == 0 { 655 - // For first page in dev mode, mix dummy data with real data 656 - let mut real_statuses = StatusFromDb::load_statuses_paginated(&db_pool, 0, limit / 2) 657 - .await 658 - .unwrap_or_else(|err| { 659 - log::error!("Error loading paginated statuses: {err}"); 660 - vec![] 661 - }); 662 - let dummy_statuses = dev_utils::generate_dummy_statuses((limit / 2) as usize); 663 - real_statuses.extend(dummy_statuses); 664 - real_statuses.sort_by(|a, b| b.started_at.cmp(&a.started_at)); 665 - real_statuses 666 - } else { 667 - StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 668 - .await 669 - .unwrap_or_else(|err| { 670 - log::error!("Error loading statuses: {err}"); 671 - vec![] 672 - }) 673 - }; 674 - 675 - // Resolve handles for each status 676 - let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 677 - for db_status in &mut statuses { 678 - let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 679 - match quick_resolve_map.get(&authors_did) { 680 - None => {} 681 - Some(found_handle) => { 682 - db_status.handle = Some(found_handle.clone()); 683 - continue; 684 - } 685 - } 686 - db_status.handle = match handle_resolver.resolve(&authors_did).await { 687 - Ok(did_doc) => match did_doc.also_known_as { 688 - None => None, 689 - Some(also_known_as) => match also_known_as.is_empty() { 690 - true => None, 691 - false => { 692 - let full_handle = also_known_as.first().unwrap(); 693 - let handle = full_handle.replace("at://", ""); 694 - quick_resolve_map.insert(authors_did, handle.clone()); 695 - Some(handle) 696 - } 697 - }, 698 - }, 699 - Err(_) => None, 700 - }; 701 - } 702 - 703 - Ok(HttpResponse::Ok().json(statuses)) 704 - } 705 - 706 - /// Get the most frequently used emojis from all statuses 707 - #[get("/api/frequent-emojis")] 708 - pub async fn get_frequent_emojis(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 709 - // Get top 20 most frequently used emojis 710 - let emojis = db::get_frequent_emojis(&db_pool, 20) 711 - .await 712 - .unwrap_or_else(|err| { 713 - log::error!("Failed to get frequent emojis: {}", err); 714 - Vec::new() 715 - }); 716 - 717 - // If we have less than 12 emojis, add some defaults to fill it out 718 - let mut result = emojis; 719 - if result.is_empty() { 720 - log::info!("No emoji usage data found, using defaults"); 721 - let defaults = vec![ 722 - "๐Ÿ˜Š", "๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐Ÿ’ฏ", "๐Ÿš€", "๐Ÿ’ช", "๐Ÿ™", "๐Ÿ‘", 723 - ]; 724 - result = defaults.into_iter().map(String::from).collect(); 725 - } else if result.len() < 12 { 726 - log::info!("Found {} emojis, padding with defaults", result.len()); 727 - let defaults = vec![ 728 - "๐Ÿ˜Š", "๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐ŸŽ‰", "๐Ÿ”ฅ", "โœจ", "๐Ÿ’ฏ", "๐Ÿš€", "๐Ÿ’ช", "๐Ÿ™", "๐Ÿ‘", 729 - ]; 730 - for emoji in defaults { 731 - if !result.contains(&emoji.to_string()) && result.len() < 20 { 732 - result.push(emoji.to_string()); 733 - } 734 - } 735 - } else { 736 - log::info!("Found {} frequently used emojis", result.len()); 737 - } 738 - 739 - Ok(web::Json(result)) 740 - } 741 - 742 - /// Get all custom emojis available on the site 743 - #[get("/api/custom-emojis")] 744 - pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> { 745 - use std::fs; 746 - 747 - #[derive(Serialize)] 748 - struct SimpleEmoji { 749 - name: String, 750 - filename: String, 751 - } 752 - 753 - let emojis_dir = app_config.emoji_dir.clone(); 754 - let mut emojis = Vec::new(); 755 - 756 - if let Ok(entries) = fs::read_dir(&emojis_dir) { 757 - for entry in entries.flatten() { 758 - if let Some(filename) = entry.file_name().to_str() { 759 - // Only include image files 760 - if filename.ends_with(".png") 761 - || filename.ends_with(".gif") 762 - || filename.ends_with(".jpg") 763 - || filename.ends_with(".webp") 764 - { 765 - // Remove file extension to get name 766 - let name = filename 767 - .rsplit_once('.') 768 - .map(|(name, _)| name) 769 - .unwrap_or(filename) 770 - .to_string(); 771 - emojis.push(SimpleEmoji { 772 - name: name.clone(), 773 - filename: filename.to_string(), 774 - }); 775 - } 776 - } 777 - } 778 - } 779 - 780 - // Sort by name 781 - emojis.sort_by(|a, b| a.name.cmp(&b.name)); 782 - 783 - Ok(HttpResponse::Ok().json(emojis)) 784 - } 785 - 786 - /// Admin-only upload of a custom emoji (PNG or GIF) 787 - #[post("/admin/upload-emoji")] 788 - pub async fn upload_emoji( 789 - session: Session, 790 - app_config: web::Data<Config>, 791 - mut payload: Multipart, 792 - ) -> Result<impl Responder> { 793 - // Require admin 794 - let did = match session.get::<String>("did").unwrap_or(None) { 795 - Some(d) => d, 796 - None => { 797 - return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ 798 - "error": "Not authenticated" 799 - }))); 800 - } 801 - }; 802 - if !is_admin(&did) { 803 - return Ok(HttpResponse::Forbidden().json(serde_json::json!({ 804 - "error": "Admin access required" 805 - }))); 806 - } 807 - 808 - // Parse multipart for optional name and the file 809 - let mut desired_name: Option<String> = None; 810 - let mut file_bytes: Option<Vec<u8>> = None; 811 - let mut file_ext: Option<&'static str> = None; // "png" | "gif" 812 - 813 - const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB cap 814 - 815 - loop { 816 - let mut field = match payload.try_next().await { 817 - Ok(Some(f)) => f, 818 - Ok(None) => break, 819 - Err(e) => { 820 - log::warn!("multipart error: {}", e); 821 - return Ok(HttpResponse::BadRequest() 822 - .json(serde_json::json!({"error":"Invalid multipart data"}))); 823 - } 824 - }; 825 - let name = field.name().to_string(); 826 - 827 - if name == "name" { 828 - // Collect small text field 829 - let mut buf = Vec::new(); 830 - loop { 831 - match field.try_next().await { 832 - Ok(Some(chunk)) => { 833 - buf.extend_from_slice(&chunk); 834 - if buf.len() > 1024 { 835 - break; 836 - } 837 - } 838 - Ok(None) => break, 839 - Err(e) => { 840 - log::warn!("multipart read error: {}", e); 841 - return Ok(HttpResponse::BadRequest() 842 - .json(serde_json::json!({"error":"Invalid multipart data"}))); 843 - } 844 - } 845 - } 846 - if let Ok(s) = String::from_utf8(buf) { 847 - desired_name = Some(s.trim().to_string()); 848 - } 849 - continue; 850 - } 851 - 852 - if name == "file" { 853 - let ct = field.content_type().cloned(); 854 - let mut ext_guess: Option<&'static str> = match ct.as_ref().map(|m| m.essence_str()) { 855 - Some("image/png") => Some("png"), 856 - Some("image/gif") => Some("gif"), 857 - _ => None, 858 - }; 859 - 860 - // Read file bytes with size cap 861 - let mut data = Vec::new(); 862 - loop { 863 - match field.try_next().await { 864 - Ok(Some(chunk)) => { 865 - data.extend_from_slice(&chunk); 866 - if data.len() > MAX_SIZE { 867 - return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 868 - "error": "File too large (max 5MB)" 869 - }))); 870 - } 871 - } 872 - Ok(None) => break, 873 - Err(e) => { 874 - log::warn!("file read error: {}", e); 875 - return Ok(HttpResponse::BadRequest() 876 - .json(serde_json::json!({"error":"Invalid file upload"}))); 877 - } 878 - } 879 - } 880 - 881 - // If content-type was ambiguous, try to infer from magic bytes 882 - if ext_guess.is_none() && data.len() >= 4 { 883 - if data.starts_with(&[0x89, b'P', b'N', b'G']) { 884 - ext_guess = Some("png"); 885 - } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { 886 - ext_guess = Some("gif"); 887 - } 888 - } 889 - 890 - if ext_guess.is_none() { 891 - return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 892 - "error": "Unsupported file type (only PNG or GIF)" 893 - }))); 894 - } 895 - 896 - file_ext = ext_guess; 897 - file_bytes = Some(data); 898 - } 899 - } 900 - 901 - let data = match file_bytes { 902 - Some(d) => d, 903 - None => { 904 - return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 905 - "error": "Missing file field" 906 - }))); 907 - } 908 - }; 909 - let ext = file_ext.unwrap_or("png"); 910 - 911 - // Sanitize/derive filename base 912 - let base = desired_name 913 - .as_ref() 914 - .cloned() 915 - .unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp())); 916 - let mut safe: String = base 917 - .chars() 918 - .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') 919 - .collect(); 920 - if safe.is_empty() { 921 - safe = "emoji".to_string(); 922 - } 923 - let mut filename = format!("{}.{}", safe.to_lowercase(), ext); 924 - 925 - // Ensure directory exists and avoid overwrite 926 - let dir = std::path::Path::new(&app_config.emoji_dir); 927 - if let Err(e) = std::fs::create_dir_all(dir) { 928 - log::error!("Failed to create emoji dir {}: {}", app_config.emoji_dir, e); 929 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 930 - "error": "Filesystem error" 931 - }))); 932 - } 933 - 934 - // If user provided a name explicitly and it conflicts with a builtin emoji slug, reject 935 - if desired_name.is_some() && is_builtin_slug(&safe.to_lowercase()).await { 936 - return Ok(HttpResponse::Conflict().json(serde_json::json!({ 937 - "error": "Name is reserved by a standard emoji.", 938 - "code": "name_exists", 939 - "name": safe.to_lowercase(), 940 - }))); 941 - } 942 - 943 - // If user provided a name explicitly and that base already exists with any supported 944 - // extension, reject with a clear error so the UI can prompt to choose a different name. 945 - if desired_name.is_some() { 946 - let png_path = dir.join(format!("{}.png", safe.to_lowercase())); 947 - let gif_path = dir.join(format!("{}.gif", safe.to_lowercase())); 948 - if png_path.exists() || gif_path.exists() { 949 - return Ok(HttpResponse::Conflict().json(serde_json::json!({ 950 - "error": "Name already exists. Choose a different name.", 951 - "code": "name_exists", 952 - "name": safe.to_lowercase(), 953 - }))); 954 - } 955 - } 956 - 957 - let mut path = dir.join(&filename); 958 - if path.exists() { 959 - // Only auto-deconflict when name wasn't provided explicitly 960 - if desired_name.is_none() { 961 - for i in 1..1000 { 962 - filename = format!("{}-{}.{}", safe.to_lowercase(), i, ext); 963 - path = dir.join(&filename); 964 - if !path.exists() { 965 - break; 966 - } 967 - } 968 - } else { 969 - return Ok(HttpResponse::Conflict().json(serde_json::json!({ 970 - "error": "Name already exists. Choose a different name.", 971 - "code": "name_exists", 972 - "name": safe.to_lowercase(), 973 - }))); 974 - } 975 - } 976 - 977 - if let Err(e) = std::fs::write(&path, &data) { 978 - log::error!("Failed to save emoji to {:?}: {}", path, e); 979 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 980 - "error": "Write failed" 981 - }))); 982 - } 983 - 984 - let url = format!("/emojis/{}", filename); 985 - Ok(HttpResponse::Ok().json(serde_json::json!({ 986 - "success": true, 987 - "filename": filename, 988 - "url": url 989 - }))) 990 - } 991 - 992 - /// Get the DIDs of accounts the logged-in user follows 993 - #[get("/api/following")] 994 - pub async fn get_following( 995 - session: Session, 996 - _oauth_client: web::Data<OAuthClientType>, 997 - ) -> Result<impl Responder> { 998 - // Check if user is logged in 999 - let did = match session.get::<Did>("did").ok().flatten() { 1000 - Some(did) => did, 1001 - None => { 1002 - return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ 1003 - "error": "Not logged in" 1004 - }))); 1005 - } 1006 - }; 1007 - 1008 - // WORKAROUND: Call public API directly for getFollows since OAuth scope isn't working 1009 - // Both getProfile and getFollows are public endpoints that don't require auth 1010 - // but when called through OAuth, getFollows requires a scope that doesn't exist yet 1011 - 1012 - let mut all_follows = Vec::new(); 1013 - let mut cursor: Option<String> = None; 1014 - 1015 - // Use reqwest to call the public API directly 1016 - let client = reqwest::Client::new(); 1017 - 1018 - loop { 1019 - let mut url = format!( 1020 - "https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor={}", 1021 - did.as_str() 1022 - ); 1023 - 1024 - if let Some(c) = &cursor { 1025 - url.push_str(&format!("&cursor={}", c)); 1026 - } 1027 - 1028 - match client.get(&url).send().await { 1029 - Ok(response) => { 1030 - match response.json::<serde_json::Value>().await { 1031 - Ok(json) => { 1032 - // Extract follows 1033 - if let Some(follows) = json["follows"].as_array() { 1034 - for follow in follows { 1035 - if let Some(did_str) = follow["did"].as_str() { 1036 - all_follows.push(did_str.to_string()); 1037 - } 1038 - } 1039 - } 1040 - 1041 - // Check for cursor 1042 - cursor = json["cursor"].as_str().map(|s| s.to_string()); 1043 - if cursor.is_none() { 1044 - break; 1045 - } 1046 - } 1047 - Err(err) => { 1048 - log::error!("Failed to parse follows response: {}", err); 1049 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 1050 - "error": "Failed to parse follows" 1051 - }))); 1052 - } 1053 - } 1054 - } 1055 - Err(err) => { 1056 - log::error!("Failed to fetch follows from public API: {}", err); 1057 - return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 1058 - "error": "Failed to fetch follows" 1059 - }))); 1060 - } 1061 - } 1062 - } 1063 - 1064 - Ok(HttpResponse::Ok().json(serde_json::json!({ 1065 - "follows": all_follows 1066 - }))) 1067 - } 1068 - 1069 - /// Clear the user's status by deleting the ATProto record 1070 - #[post("/status/clear")] 1071 - pub async fn clear_status( 1072 - request: HttpRequest, 1073 - session: Session, 1074 - oauth_client: web::Data<OAuthClientType>, 1075 - db_pool: web::Data<Arc<Pool>>, 1076 - ) -> HttpResponse { 1077 - // Check if the user is logged in 1078 - match session.get::<String>("did").unwrap_or(None) { 1079 - Some(did_string) => { 1080 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1081 - 1082 - // Get the user's current status to find the record key 1083 - match StatusFromDb::my_status(&db_pool, &did).await { 1084 - Ok(Some(current_status)) => { 1085 - // Extract the record key from the URI 1086 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1087 - let parts: Vec<&str> = current_status.uri.split('/').collect(); 1088 - if let Some(rkey) = parts.last() { 1089 - // Get OAuth session 1090 - match oauth_client.restore(&did).await { 1091 - Ok(session) => { 1092 - let agent = Agent::new(session); 1093 - 1094 - // Delete the record from ATProto using com.atproto.repo.deleteRecord 1095 - let delete_request = 1096 - atrium_api::com::atproto::repo::delete_record::InputData { 1097 - collection: atrium_api::types::string::Nsid::new( 1098 - "io.zzstoatzz.status.record".to_string(), 1099 - ) 1100 - .expect("valid nsid"), 1101 - repo: did.clone().into(), 1102 - rkey: atrium_api::types::string::RecordKey::new( 1103 - rkey.to_string(), 1104 - ) 1105 - .expect("valid rkey"), 1106 - swap_commit: None, 1107 - swap_record: None, 1108 - }; 1109 - match agent 1110 - .api 1111 - .com 1112 - .atproto 1113 - .repo 1114 - .delete_record(delete_request.into()) 1115 - .await 1116 - { 1117 - Ok(_) => { 1118 - // Also remove from local database 1119 - let _ = StatusFromDb::delete_by_uri( 1120 - &db_pool, 1121 - current_status.uri, 1122 - ) 1123 - .await; 1124 - 1125 - Redirect::to("/") 1126 - .see_other() 1127 - .respond_to(&request) 1128 - .map_into_boxed_body() 1129 - } 1130 - Err(e) => { 1131 - log::error!("Failed to delete status from ATProto: {e}"); 1132 - HttpResponse::InternalServerError() 1133 - .body("Failed to clear status") 1134 - } 1135 - } 1136 - } 1137 - Err(e) => { 1138 - log::error!("Failed to restore OAuth session: {e}"); 1139 - HttpResponse::InternalServerError().body("Session error") 1140 - } 1141 - } 1142 - } else { 1143 - HttpResponse::BadRequest().body("Invalid status URI") 1144 - } 1145 - } 1146 - Ok(None) => { 1147 - // No status to clear 1148 - Redirect::to("/") 1149 - .see_other() 1150 - .respond_to(&request) 1151 - .map_into_boxed_body() 1152 - } 1153 - Err(e) => { 1154 - log::error!("Database error: {e}"); 1155 - HttpResponse::InternalServerError().body("Database error") 1156 - } 1157 - } 1158 - } 1159 - None => { 1160 - // Not logged in 1161 - Redirect::to("/login") 1162 - .see_other() 1163 - .respond_to(&request) 1164 - .map_into_boxed_body() 1165 - } 1166 - } 1167 - } 1168 - 1169 - /// Delete a specific status by URI (JSON endpoint) 1170 - #[post("/status/delete")] 1171 - pub async fn delete_status( 1172 - session: Session, 1173 - oauth_client: web::Data<OAuthClientType>, 1174 - db_pool: web::Data<Arc<Pool>>, 1175 - req: web::Json<DeleteRequest>, 1176 - ) -> HttpResponse { 1177 - // Check if the user is logged in 1178 - match session.get::<String>("did").unwrap_or(None) { 1179 - Some(did_string) => { 1180 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1181 - 1182 - // Parse the URI to verify it belongs to this user 1183 - // URI format: at://did:plc:xxx/io.zzstoatzz.status.record/rkey 1184 - let uri_parts: Vec<&str> = req.uri.split('/').collect(); 1185 - if uri_parts.len() < 5 { 1186 - return HttpResponse::BadRequest().json(serde_json::json!({ 1187 - "error": "Invalid status URI format" 1188 - })); 1189 - } 1190 - 1191 - // Extract DID from URI (at://did:plc:xxx/...) 1192 - let uri_did_part = uri_parts[2]; 1193 - if uri_did_part != did_string { 1194 - return HttpResponse::Forbidden().json(serde_json::json!({ 1195 - "error": "You can only delete your own statuses" 1196 - })); 1197 - } 1198 - 1199 - // Extract record key 1200 - if let Some(rkey) = uri_parts.last() { 1201 - // Get OAuth session 1202 - match oauth_client.restore(&did).await { 1203 - Ok(session) => { 1204 - let agent = Agent::new(session); 1205 - 1206 - // Delete the record from ATProto 1207 - let delete_request = 1208 - atrium_api::com::atproto::repo::delete_record::InputData { 1209 - collection: atrium_api::types::string::Nsid::new( 1210 - "io.zzstoatzz.status.record".to_string(), 1211 - ) 1212 - .expect("valid nsid"), 1213 - repo: did.clone().into(), 1214 - rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 1215 - .expect("valid rkey"), 1216 - swap_commit: None, 1217 - swap_record: None, 1218 - }; 1219 - 1220 - match agent 1221 - .api 1222 - .com 1223 - .atproto 1224 - .repo 1225 - .delete_record(delete_request.into()) 1226 - .await 1227 - { 1228 - Ok(_) => { 1229 - // Also remove from local database 1230 - let _ = 1231 - StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 1232 - 1233 - HttpResponse::Ok().json(serde_json::json!({ 1234 - "success": true 1235 - })) 1236 - } 1237 - Err(e) => { 1238 - log::error!("Failed to delete status from ATProto: {e}"); 1239 - HttpResponse::InternalServerError().json(serde_json::json!({ 1240 - "error": "Failed to delete status" 1241 - })) 1242 - } 1243 - } 1244 - } 1245 - Err(e) => { 1246 - log::error!("Failed to restore OAuth session: {e}"); 1247 - HttpResponse::InternalServerError().json(serde_json::json!({ 1248 - "error": "Session error" 1249 - })) 1250 - } 1251 - } 1252 - } else { 1253 - HttpResponse::BadRequest().json(serde_json::json!({ 1254 - "error": "Invalid status URI" 1255 - })) 1256 - } 1257 - } 1258 - None => { 1259 - // Not logged in 1260 - HttpResponse::Unauthorized().json(serde_json::json!({ 1261 - "error": "Not authenticated" 1262 - })) 1263 - } 1264 - } 1265 - } 1266 - 1267 - /// Hide/unhide a status (admin only) 1268 - #[post("/admin/hide-status")] 1269 - pub async fn hide_status( 1270 - session: Session, 1271 - db_pool: web::Data<Arc<Pool>>, 1272 - req: web::Json<HideStatusRequest>, 1273 - ) -> HttpResponse { 1274 - // Check if the user is logged in and is admin 1275 - match session.get::<String>("did").unwrap_or(None) { 1276 - Some(did_string) => { 1277 - if !is_admin(&did_string) { 1278 - return HttpResponse::Forbidden().json(serde_json::json!({ 1279 - "error": "Admin access required" 1280 - })); 1281 - } 1282 - 1283 - // Update the hidden status in the database 1284 - let uri = req.uri.clone(); 1285 - let hidden = req.hidden; 1286 - 1287 - let result = db_pool 1288 - .conn(move |conn| { 1289 - conn.execute( 1290 - "UPDATE status SET hidden = ?1 WHERE uri = ?2", 1291 - rusqlite::params![hidden, uri], 1292 - ) 1293 - }) 1294 - .await; 1295 - 1296 - match result { 1297 - Ok(rows_affected) if rows_affected > 0 => { 1298 - HttpResponse::Ok().json(serde_json::json!({ 1299 - "success": true, 1300 - "message": if hidden { "Status hidden" } else { "Status unhidden" } 1301 - })) 1302 - } 1303 - Ok(_) => HttpResponse::NotFound().json(serde_json::json!({ 1304 - "error": "Status not found" 1305 - })), 1306 - Err(err) => { 1307 - log::error!("Error updating hidden status: {}", err); 1308 - HttpResponse::InternalServerError().json(serde_json::json!({ 1309 - "error": "Database error" 1310 - })) 1311 - } 1312 - } 1313 - } 1314 - None => HttpResponse::Unauthorized().json(serde_json::json!({ 1315 - "error": "Not authenticated" 1316 - })), 1317 - } 1318 - } 1319 - 1320 - /// Creates a new status 1321 - #[post("/status")] 1322 - pub async fn status( 1323 - request: HttpRequest, 1324 - session: Session, 1325 - oauth_client: web::Data<OAuthClientType>, 1326 - db_pool: web::Data<Arc<Pool>>, 1327 - form: web::Form<StatusForm>, 1328 - rate_limiter: web::Data<RateLimiter>, 1329 - ) -> Result<HttpResponse, AppError> { 1330 - // Apply rate limiting 1331 - let client_key = RateLimiter::get_client_key(&request); 1332 - if !rate_limiter.check_rate_limit(&client_key) { 1333 - return Err(AppError::RateLimitExceeded); 1334 - } 1335 - // Check if the user is logged in 1336 - match session.get::<String>("did").unwrap_or(None) { 1337 - Some(did_string) => { 1338 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 1339 - // gets the user's session from the session store to resume 1340 - match oauth_client.restore(&did).await { 1341 - Ok(session) => { 1342 - let agent = Agent::new(session); 1343 - 1344 - // Calculate expiration time if provided 1345 - let expires = form 1346 - .expires_in 1347 - .as_ref() 1348 - .and_then(|exp| parse_duration(exp)) 1349 - .and_then(|duration| { 1350 - let expiry_time = chrono::Utc::now() + duration; 1351 - // Convert to ATProto Datetime format (RFC3339) 1352 - Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 1353 - }); 1354 - 1355 - //Creates a strongly typed ATProto record 1356 - let status: KnownRecord = 1357 - crate::lexicons::io::zzstoatzz::status::record::RecordData { 1358 - created_at: Datetime::now(), 1359 - emoji: form.status.clone(), 1360 - text: form.text.clone(), 1361 - expires, 1362 - } 1363 - .into(); 1364 - 1365 - // TODO no data validation yet from esquema 1366 - // Maybe you'd like to add it? https://github.com/fatfingers23/esquema/issues/3 1367 - 1368 - let create_result = agent 1369 - .api 1370 - .com 1371 - .atproto 1372 - .repo 1373 - .create_record( 1374 - atrium_api::com::atproto::repo::create_record::InputData { 1375 - collection: "io.zzstoatzz.status.record".parse().unwrap(), 1376 - repo: did.into(), 1377 - rkey: None, 1378 - record: status.into(), 1379 - swap_commit: None, 1380 - validate: None, 1381 - } 1382 - .into(), 1383 - ) 1384 - .await; 1385 - 1386 - match create_result { 1387 - Ok(record) => { 1388 - let mut status = StatusFromDb::new( 1389 - record.uri.clone(), 1390 - did_string, 1391 - form.status.clone(), 1392 - ); 1393 - 1394 - // Set the text field if provided 1395 - status.text = form.text.clone(); 1396 - 1397 - // Set the expiration time if provided 1398 - if let Some(exp_str) = &form.expires_in { 1399 - if let Some(duration) = parse_duration(exp_str) { 1400 - status.expires_at = Some(chrono::Utc::now() + duration); 1401 - } 1402 - } 1403 - 1404 - let _ = status.save(db_pool).await; 1405 - Ok(Redirect::to("/") 1406 - .see_other() 1407 - .respond_to(&request) 1408 - .map_into_boxed_body()) 1409 - } 1410 - Err(err) => { 1411 - log::error!("Error creating status: {err}"); 1412 - let error_html = ErrorTemplate { 1413 - title: "Error", 1414 - error: "Was an error creating the status, please check the logs.", 1415 - } 1416 - .render() 1417 - .expect("template should be valid"); 1418 - Ok(HttpResponse::Ok().body(error_html)) 1419 - } 1420 - } 1421 - } 1422 - Err(err) => { 1423 - // Destroys the system or you're in a loop 1424 - session.purge(); 1425 - log::error!( 1426 - "Error restoring session, we are removing the session from the cookie: {err}" 1427 - ); 1428 - Err(AppError::AuthenticationError("Session error".to_string())) 1429 - } 1430 - } 1431 - } 1432 - None => Err(AppError::AuthenticationError( 1433 - "You must be logged in to create a status.".to_string(), 1434 - )), 1435 - } 1436 - }
+448
src/api/status_read.rs
··· 1 + use crate::config::Config; 2 + use crate::db; 3 + use crate::resolver::HickoryDnsTxtResolver; 4 + use crate::{ 5 + api::auth::OAuthClientType, 6 + db::StatusFromDb, 7 + templates::{ErrorTemplate, FeedTemplate, StatusTemplate}, 8 + }; 9 + use actix_session::Session; 10 + use actix_web::{Responder, Result, get, web}; 11 + use askama::Template; 12 + use async_sqlite::Pool; 13 + use atrium_api::types::string::Did; 14 + use atrium_common::resolver::Resolver; 15 + use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; 16 + use atrium_oauth::DefaultHttpClient; 17 + use serde_json::json; 18 + use std::sync::Arc; 19 + 20 + use crate::api::status_util::{HandleResolver, is_admin}; 21 + 22 + /// Homepage - shows logged-in user's status, or owner's status if not logged in 23 + #[get("/")] 24 + pub async fn home( 25 + session: Session, 26 + _oauth_client: web::Data<OAuthClientType>, 27 + db_pool: web::Data<Arc<Pool>>, 28 + handle_resolver: web::Data<HandleResolver>, 29 + ) -> Result<impl Responder> { 30 + // Default owner of the domain 31 + const OWNER_HANDLE: &str = "zzstoatzz.io"; 32 + 33 + match session.get::<String>("did").unwrap_or(None) { 34 + Some(did_string) => { 35 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 36 + let handle = match handle_resolver.resolve(&did).await { 37 + Ok(did_doc) => did_doc 38 + .also_known_as 39 + .and_then(|aka| aka.first().map(|h| h.replace("at://", ""))) 40 + .unwrap_or_else(|| did_string.clone()), 41 + Err(_) => did_string.clone(), 42 + }; 43 + let current_status = StatusFromDb::my_status(&db_pool, &did) 44 + .await 45 + .unwrap_or(None) 46 + .and_then(|s| { 47 + if let Some(expires_at) = s.expires_at { 48 + if chrono::Utc::now() > expires_at { 49 + return None; 50 + } 51 + } 52 + Some(s) 53 + }); 54 + let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 55 + .await 56 + .unwrap_or_else(|err| { 57 + log::error!("Error loading status history: {err}"); 58 + vec![] 59 + }); 60 + let is_admin_flag = is_admin(did.as_str()); 61 + let html = StatusTemplate { 62 + title: "your status", 63 + handle, 64 + current_status, 65 + history, 66 + is_owner: true, 67 + is_admin: is_admin_flag, 68 + } 69 + .render() 70 + .expect("template should be valid"); 71 + Ok(web::Html::new(html)) 72 + } 73 + None => { 74 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 75 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 76 + http_client: Arc::new(DefaultHttpClient::default()), 77 + }); 78 + let owner_handle = 79 + atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 80 + let owner_did = if let Some(handle) = owner_handle { 81 + atproto_handle_resolver.resolve(&handle).await.ok() 82 + } else { 83 + None 84 + }; 85 + let current_status = if let Some(ref did) = owner_did { 86 + StatusFromDb::my_status(&db_pool, did) 87 + .await 88 + .unwrap_or(None) 89 + .and_then(|s| { 90 + if let Some(expires_at) = s.expires_at { 91 + if chrono::Utc::now() > expires_at { 92 + return None; 93 + } 94 + } 95 + Some(s) 96 + }) 97 + } else { 98 + None 99 + }; 100 + let history = if let Some(ref did) = owner_did { 101 + StatusFromDb::load_user_statuses(&db_pool, did, 10) 102 + .await 103 + .unwrap_or_else(|err| { 104 + log::error!("Error loading status history: {err}"); 105 + vec![] 106 + }) 107 + } else { 108 + vec![] 109 + }; 110 + let html = StatusTemplate { 111 + title: "nate's status", 112 + handle: OWNER_HANDLE.to_string(), 113 + current_status, 114 + history, 115 + is_owner: false, 116 + is_admin: false, 117 + } 118 + .render() 119 + .expect("template should be valid"); 120 + Ok(web::Html::new(html)) 121 + } 122 + } 123 + } 124 + 125 + /// View a specific user's status page by handle 126 + #[get("/@{handle}")] 127 + pub async fn user_status_page( 128 + handle: web::Path<String>, 129 + session: Session, 130 + db_pool: web::Data<Arc<Pool>>, 131 + _handle_resolver: web::Data<HandleResolver>, 132 + ) -> Result<impl Responder> { 133 + let handle = handle.into_inner(); 134 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 135 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 136 + http_client: Arc::new(DefaultHttpClient::default()), 137 + }); 138 + let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 139 + let did = match handle_obj { 140 + Some(h) => match atproto_handle_resolver.resolve(&h).await { 141 + Ok(did) => did, 142 + Err(_) => { 143 + let html = ErrorTemplate { 144 + title: "User not found", 145 + error: &format!("Could not find user @{}.", handle), 146 + } 147 + .render() 148 + .expect("template should be valid"); 149 + return Ok(web::Html::new(html)); 150 + } 151 + }, 152 + None => { 153 + let html = ErrorTemplate { 154 + title: "Invalid handle", 155 + error: &format!("'{}' is not a valid handle format.", handle), 156 + } 157 + .render() 158 + .expect("template should be valid"); 159 + return Ok(web::Html::new(html)); 160 + } 161 + }; 162 + let is_owner = match session.get::<String>("did").unwrap_or(None) { 163 + Some(session_did) => session_did == did.to_string(), 164 + None => false, 165 + }; 166 + let current_status = StatusFromDb::my_status(&db_pool, &did) 167 + .await 168 + .unwrap_or(None) 169 + .and_then(|s| { 170 + if let Some(expires_at) = s.expires_at { 171 + if chrono::Utc::now() > expires_at { 172 + return None; 173 + } 174 + } 175 + Some(s) 176 + }); 177 + let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 178 + .await 179 + .unwrap_or_else(|err| { 180 + log::error!("Error loading status history: {err}"); 181 + vec![] 182 + }); 183 + let html = StatusTemplate { 184 + title: &format!("@{} status", handle), 185 + handle, 186 + current_status, 187 + history, 188 + is_owner, 189 + is_admin: false, 190 + } 191 + .render() 192 + .expect("template should be valid"); 193 + Ok(web::Html::new(html)) 194 + } 195 + 196 + #[get("/json")] 197 + pub async fn owner_status_json( 198 + _session: Session, 199 + db_pool: web::Data<Arc<Pool>>, 200 + _handle_resolver: web::Data<HandleResolver>, 201 + ) -> Result<impl Responder> { 202 + // Resolve owner handle to DID (zzstoatzz.io) 203 + let owner_handle = atrium_api::types::string::Handle::new("zzstoatzz.io".to_string()).ok(); 204 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 205 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 206 + http_client: Arc::new(DefaultHttpClient::default()), 207 + }); 208 + let did = if let Some(handle) = owner_handle { 209 + atproto_handle_resolver.resolve(&handle).await.ok() 210 + } else { 211 + None 212 + }; 213 + let current_status = if let Some(did) = did { 214 + StatusFromDb::my_status(&db_pool, &did) 215 + .await 216 + .unwrap_or(None) 217 + .and_then(|s| { 218 + if let Some(expires_at) = s.expires_at { 219 + if chrono::Utc::now() > expires_at { 220 + return None; 221 + } 222 + } 223 + Some(s) 224 + }) 225 + } else { 226 + None 227 + }; 228 + let response = if let Some(status_data) = current_status { 229 + json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 230 + } else { 231 + json!({ "status": "unknown", "message": "No current status is known" }) 232 + }; 233 + Ok(web::Json(response)) 234 + } 235 + 236 + #[get("/@{handle}/json")] 237 + pub async fn user_status_json( 238 + handle: web::Path<String>, 239 + _session: Session, 240 + db_pool: web::Data<Arc<Pool>>, 241 + ) -> Result<impl Responder> { 242 + let handle = handle.into_inner(); 243 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 244 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 245 + http_client: Arc::new(DefaultHttpClient::default()), 246 + }); 247 + let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 248 + let did = if let Some(h) = handle_obj { 249 + atproto_handle_resolver.resolve(&h).await.ok() 250 + } else { 251 + None 252 + }; 253 + if let Some(did) = did { 254 + let current_status = StatusFromDb::my_status(&db_pool, &did) 255 + .await 256 + .unwrap_or(None) 257 + .and_then(|s| { 258 + if let Some(expires_at) = s.expires_at { 259 + if chrono::Utc::now() > expires_at { 260 + return None; 261 + } 262 + } 263 + Some(s) 264 + }); 265 + let response = if let Some(status_data) = current_status { 266 + json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 267 + } else { 268 + json!({ "status": "unknown", "message": format!("No current status is known for @{}", handle) }) 269 + }; 270 + Ok(web::Json(response)) 271 + } else { 272 + Ok(web::Json( 273 + json!({ "status": "unknown", "message": format!("Unknown user @{}", handle) }), 274 + )) 275 + } 276 + } 277 + 278 + #[get("/api/status")] 279 + pub async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 280 + // Owner: zzstoatzz.io 281 + let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 282 + dns_txt_resolver: HickoryDnsTxtResolver::default(), 283 + http_client: Arc::new(DefaultHttpClient::default()), 284 + }); 285 + let owner_handle = atrium_api::types::string::Handle::new("zzstoatzz.io".to_string()).ok(); 286 + let did = if let Some(h) = owner_handle { 287 + atproto_handle_resolver.resolve(&h).await.ok() 288 + } else { 289 + None 290 + }; 291 + let current_status = if let Some(ref did) = did { 292 + StatusFromDb::my_status(&db_pool, did) 293 + .await 294 + .unwrap_or(None) 295 + .and_then(|s| { 296 + if let Some(expires_at) = s.expires_at { 297 + if chrono::Utc::now() > expires_at { 298 + return None; 299 + } 300 + } 301 + Some(s) 302 + }) 303 + } else { 304 + None 305 + }; 306 + let response = if let Some(status_data) = current_status { 307 + json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 308 + } else { 309 + json!({ "status": "unknown", "message": "No current status is known" }) 310 + }; 311 + Ok(web::Json(response)) 312 + } 313 + 314 + #[get("/feed")] 315 + pub async fn feed( 316 + session: Session, 317 + _db_pool: web::Data<Arc<Pool>>, 318 + handle_resolver: web::Data<HandleResolver>, 319 + app_config: web::Data<Config>, 320 + ) -> Result<impl Responder> { 321 + let did_opt = session.get::<String>("did").unwrap_or(None); 322 + let is_admin_flag = did_opt.as_deref().map(is_admin).unwrap_or(false); 323 + 324 + let mut profile: Option<crate::templates::Profile> = None; 325 + if let Some(did_str) = did_opt.clone() { 326 + let mut handle_opt: Option<String> = None; 327 + if let Ok(doc) = handle_resolver 328 + .resolve(&atrium_api::types::string::Did::new(did_str.clone()).expect("did")) 329 + .await 330 + { 331 + if let Some(h) = doc.also_known_as.and_then(|aka| aka.first().cloned()) { 332 + handle_opt = Some(h.replace("at://", "")); 333 + } 334 + } 335 + profile = Some(crate::templates::Profile { 336 + did: did_str, 337 + display_name: None, 338 + handle: handle_opt, 339 + }); 340 + } 341 + 342 + let html = FeedTemplate { 343 + title: "feed", 344 + profile, 345 + statuses: vec![], 346 + is_admin: is_admin_flag, 347 + dev_mode: app_config.dev_mode, 348 + } 349 + .render() 350 + .expect("template should be valid"); 351 + Ok(web::Html::new(html)) 352 + } 353 + 354 + #[get("/api/feed")] 355 + pub async fn api_feed( 356 + db_pool: web::Data<Arc<Pool>>, 357 + handle_resolver: web::Data<HandleResolver>, 358 + query: web::Query<std::collections::HashMap<String, String>>, 359 + ) -> Result<impl Responder> { 360 + // Paginated feed 361 + let offset = query 362 + .get("offset") 363 + .and_then(|s| s.parse::<i32>().ok()) 364 + .unwrap_or(0); 365 + let limit = query 366 + .get("limit") 367 + .and_then(|s| s.parse::<i32>().ok()) 368 + .unwrap_or(20) 369 + .clamp(5, 50); 370 + 371 + let statuses = StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 372 + .await 373 + .unwrap_or_default(); 374 + let mut enriched = Vec::with_capacity(statuses.len()); 375 + for mut s in statuses { 376 + // Resolve handle lazily 377 + let did = Did::new(s.author_did.clone()).expect("did"); 378 + if let Ok(doc) = handle_resolver.resolve(&did).await { 379 + if let Some(h) = doc.also_known_as.and_then(|aka| aka.first().cloned()) { 380 + s.handle = Some(h.replace("at://", "")); 381 + } 382 + } 383 + enriched.push(s); 384 + } 385 + let has_more = (enriched.len() as i32) == limit; 386 + Ok(web::Json( 387 + json!({ "statuses": enriched, "has_more": has_more, "next_offset": offset + (enriched.len() as i32) }), 388 + )) 389 + } 390 + 391 + #[get("/api/frequent-emojis")] 392 + pub async fn get_frequent_emojis(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 393 + let emojis = db::get_frequent_emojis(&db_pool, 20) 394 + .await 395 + .unwrap_or_default(); 396 + // Legacy response shape: raw array, not wrapped 397 + Ok(web::Json(emojis)) 398 + } 399 + 400 + #[get("/api/custom-emojis")] 401 + pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> { 402 + // Response shape expected by UI: 403 + // [ { "name": "sparkle", "filename": "sparkle.png" }, ... ] 404 + let dir = app_config.emoji_dir.clone(); 405 + let fs_dir = std::path::Path::new(&dir); 406 + let fallback = std::path::Path::new("static/emojis"); 407 + 408 + let mut map: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); 409 + let read_dirs = [fs_dir, fallback]; 410 + for d in read_dirs.iter() { 411 + if let Ok(entries) = std::fs::read_dir(d) { 412 + for entry in entries.flatten() { 413 + let p = entry.path(); 414 + if let (Some(stem), Some(ext)) = (p.file_stem(), p.extension()) { 415 + let name = stem.to_string_lossy().to_string(); 416 + let ext = ext.to_string_lossy().to_ascii_lowercase(); 417 + if ext == "png" || ext == "gif" { 418 + // prefer png over gif if duplicates 419 + let filename = format!("{}.{ext}", name); 420 + map.entry(name) 421 + .and_modify(|v| { 422 + if v.ends_with(".gif") && ext == "png" { 423 + *v = filename.clone(); 424 + } 425 + }) 426 + .or_insert(filename); 427 + } 428 + } 429 + } 430 + } 431 + } 432 + 433 + let custom: Vec<serde_json::Value> = map 434 + .into_iter() 435 + .map(|(name, filename)| json!({ "name": name, "filename": filename })) 436 + .collect(); 437 + Ok(web::Json(custom)) 438 + } 439 + 440 + #[get("/api/following")] 441 + pub async fn get_following( 442 + _session: Session, 443 + _oauth_client: web::Data<OAuthClientType>, 444 + _db_pool: web::Data<Arc<Pool>>, 445 + ) -> Result<impl Responder> { 446 + // Placeholder: follow list disabled here to keep module slim 447 + Ok(web::Json(json!({ "follows": [] }))) 448 + }
+54
src/api/status_util.rs
··· 1 + use atrium_identity::did::CommonDidResolver; 2 + use atrium_oauth::DefaultHttpClient; 3 + use serde::{Deserialize, Serialize}; 4 + use std::sync::Arc; 5 + 6 + /// HandleResolver to make it easier to access the OAuthClient in web requests 7 + pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 8 + 9 + /// Admin DID for moderation 10 + pub const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 11 + 12 + /// Check if a DID is the admin 13 + pub fn is_admin(did: &str) -> bool { 14 + did == ADMIN_DID 15 + } 16 + 17 + /// The post body for changing your status 18 + #[derive(Serialize, Deserialize, Clone)] 19 + pub struct StatusForm { 20 + pub status: String, 21 + pub text: Option<String>, 22 + pub expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 23 + } 24 + 25 + /// The post body for deleting a specific status 26 + #[derive(Serialize, Deserialize)] 27 + pub struct DeleteRequest { 28 + pub uri: String, 29 + } 30 + 31 + /// Hide/unhide a status (admin only) 32 + #[derive(Deserialize)] 33 + pub struct HideStatusRequest { 34 + pub uri: String, 35 + pub hidden: bool, 36 + } 37 + 38 + /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 39 + pub fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 40 + if duration_str.is_empty() { 41 + return None; 42 + } 43 + 44 + let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 45 + let num: i64 = num_str.parse().ok()?; 46 + 47 + match unit { 48 + "m" => Some(chrono::Duration::minutes(num)), 49 + "h" => Some(chrono::Duration::hours(num)), 50 + "d" => Some(chrono::Duration::days(num)), 51 + "w" => Some(chrono::Duration::weeks(num)), 52 + _ => None, 53 + } 54 + }
+373
src/api/status_write.rs
··· 1 + use crate::config::Config; 2 + use crate::{ 3 + api::auth::OAuthClientType, db::StatusFromDb, error_handler::AppError, 4 + lexicons::record::KnownRecord, rate_limiter::RateLimiter, 5 + }; 6 + use actix_multipart::Multipart; 7 + use actix_session::Session; 8 + use actix_web::{HttpRequest, HttpResponse, Responder, post, web}; 9 + use async_sqlite::{Pool, rusqlite}; 10 + use atrium_api::{ 11 + agent::Agent, 12 + types::string::{Datetime, Did}, 13 + }; 14 + use futures_util::TryStreamExt as _; 15 + use std::sync::Arc; 16 + 17 + use crate::api::status_util::{HideStatusRequest, StatusForm, parse_duration}; 18 + 19 + #[post("/admin/upload-emoji")] 20 + pub async fn upload_emoji( 21 + session: Session, 22 + mut payload: Multipart, 23 + app_config: web::Data<Config>, 24 + ) -> Result<impl Responder, AppError> { 25 + if session.get::<String>("did").unwrap_or(None).is_none() { 26 + return Ok(HttpResponse::Unauthorized().body("Not authenticated")); 27 + } 28 + let mut name: Option<String> = None; 29 + let mut file_bytes: Option<Vec<u8>> = None; 30 + while let Some(item) = payload 31 + .try_next() 32 + .await 33 + .map_err(|e| AppError::ValidationError(e.to_string()))? 34 + { 35 + let mut field = item; 36 + let disp = field.content_disposition().clone(); 37 + let field_name = disp.get_name().unwrap_or(""); 38 + if field_name == "name" { 39 + let mut buf = Vec::new(); 40 + while let Some(chunk) = field 41 + .try_next() 42 + .await 43 + .map_err(|e| AppError::ValidationError(e.to_string()))? 44 + { 45 + buf.extend_from_slice(&chunk); 46 + } 47 + name = Some(String::from_utf8_lossy(&buf).trim().to_string()); 48 + } else if field_name == "file" { 49 + let mut buf = Vec::new(); 50 + while let Some(chunk) = field 51 + .try_next() 52 + .await 53 + .map_err(|e| AppError::ValidationError(e.to_string()))? 54 + { 55 + buf.extend_from_slice(&chunk); 56 + } 57 + file_bytes = Some(buf); 58 + } 59 + } 60 + let file_bytes = file_bytes.ok_or_else(|| AppError::ValidationError("No file".into()))?; 61 + // Basic validation omitted for brevity 62 + let emoji_dir = app_config.emoji_dir.clone(); 63 + let filename = name 64 + .filter(|s| !s.is_empty()) 65 + .unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp())); 66 + let path_png = format!("{}/{}.png", emoji_dir, filename); 67 + std::fs::write(&path_png, &file_bytes).map_err(|e| AppError::ValidationError(e.to_string()))?; 68 + Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true, "name": filename}))) 69 + } 70 + 71 + /// Clear the user's status by deleting the ATProto record 72 + #[post("/status/clear")] 73 + pub async fn clear_status( 74 + request: HttpRequest, 75 + session: Session, 76 + oauth_client: web::Data<OAuthClientType>, 77 + db_pool: web::Data<Arc<Pool>>, 78 + ) -> HttpResponse { 79 + match session.get::<String>("did").unwrap_or(None) { 80 + Some(did_string) => { 81 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 82 + match StatusFromDb::my_status(&db_pool, &did).await { 83 + Ok(Some(current_status)) => { 84 + let parts: Vec<&str> = current_status.uri.split('/').collect(); 85 + if let Some(rkey) = parts.last() { 86 + match oauth_client.restore(&did).await { 87 + Ok(session) => { 88 + let agent = Agent::new(session); 89 + let delete_request = 90 + atrium_api::com::atproto::repo::delete_record::InputData { 91 + collection: atrium_api::types::string::Nsid::new( 92 + "io.zzstoatzz.status.record".to_string(), 93 + ) 94 + .expect("valid nsid"), 95 + repo: did.clone().into(), 96 + rkey: atrium_api::types::string::RecordKey::new( 97 + rkey.to_string(), 98 + ) 99 + .expect("valid rkey"), 100 + swap_commit: None, 101 + swap_record: None, 102 + }; 103 + match agent 104 + .api 105 + .com 106 + .atproto 107 + .repo 108 + .delete_record(delete_request.into()) 109 + .await 110 + { 111 + Ok(_) => { 112 + let _ = StatusFromDb::delete_by_uri( 113 + &db_pool, 114 + current_status.uri.clone(), 115 + ) 116 + .await; 117 + let pool = db_pool.get_ref().clone(); 118 + let did_for_event = did_string.clone(); 119 + let uri = current_status.uri.clone(); 120 + tokio::spawn(async move { 121 + crate::webhooks::emit_deleted( 122 + pool, 123 + &did_for_event, 124 + &uri, 125 + ) 126 + .await; 127 + }); 128 + web::Redirect::to("/") 129 + .see_other() 130 + .respond_to(&request) 131 + .map_into_boxed_body() 132 + } 133 + Err(e) => { 134 + log::error!("Failed to delete status from ATProto: {e}"); 135 + HttpResponse::InternalServerError() 136 + .body("Failed to clear status") 137 + } 138 + } 139 + } 140 + Err(e) => { 141 + log::error!("Failed to restore OAuth session: {e}"); 142 + HttpResponse::InternalServerError().body("Session error") 143 + } 144 + } 145 + } else { 146 + HttpResponse::BadRequest().body("Invalid status URI") 147 + } 148 + } 149 + Ok(None) => web::Redirect::to("/") 150 + .see_other() 151 + .respond_to(&request) 152 + .map_into_boxed_body(), 153 + Err(e) => { 154 + log::error!("Database error: {e}"); 155 + HttpResponse::InternalServerError().body("Database error") 156 + } 157 + } 158 + } 159 + None => web::Redirect::to("/login") 160 + .see_other() 161 + .respond_to(&request) 162 + .map_into_boxed_body(), 163 + } 164 + } 165 + 166 + /// Delete a specific status by URI (JSON endpoint) 167 + #[post("/status/delete")] 168 + pub async fn delete_status( 169 + session: Session, 170 + oauth_client: web::Data<OAuthClientType>, 171 + db_pool: web::Data<Arc<Pool>>, 172 + req: web::Json<crate::api::status_util::DeleteRequest>, 173 + ) -> HttpResponse { 174 + match session.get::<String>("did").unwrap_or(None) { 175 + Some(did_string) => { 176 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 177 + let uri_parts: Vec<&str> = req.uri.split('/').collect(); 178 + if uri_parts.len() < 5 { 179 + return HttpResponse::BadRequest() 180 + .json(serde_json::json!({"error":"Invalid status URI format"})); 181 + } 182 + let uri_did_part = uri_parts[2]; 183 + if uri_did_part != did_string { 184 + return HttpResponse::Forbidden() 185 + .json(serde_json::json!({"error":"You can only delete your own statuses"})); 186 + } 187 + if let Some(rkey) = uri_parts.last() { 188 + match oauth_client.restore(&did).await { 189 + Ok(session) => { 190 + let agent = Agent::new(session); 191 + let delete_request = 192 + atrium_api::com::atproto::repo::delete_record::InputData { 193 + collection: atrium_api::types::string::Nsid::new( 194 + "io.zzstoatzz.status.record".to_string(), 195 + ) 196 + .expect("valid nsid"), 197 + repo: did.clone().into(), 198 + rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 199 + .expect("valid rkey"), 200 + swap_commit: None, 201 + swap_record: None, 202 + }; 203 + match agent 204 + .api 205 + .com 206 + .atproto 207 + .repo 208 + .delete_record(delete_request.into()) 209 + .await 210 + { 211 + Ok(_) => { 212 + let _ = 213 + StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 214 + let pool = db_pool.get_ref().clone(); 215 + let did_for_event = did_string.clone(); 216 + let uri = req.uri.clone(); 217 + tokio::spawn(async move { 218 + crate::webhooks::emit_deleted(pool, &did_for_event, &uri).await; 219 + }); 220 + HttpResponse::Ok().json(serde_json::json!({"success":true})) 221 + } 222 + Err(e) => { 223 + log::error!("Failed to delete status from ATProto: {e}"); 224 + HttpResponse::InternalServerError() 225 + .json(serde_json::json!({"error":"Failed to delete status"})) 226 + } 227 + } 228 + } 229 + Err(e) => { 230 + log::error!("Failed to restore OAuth session: {e}"); 231 + HttpResponse::InternalServerError() 232 + .json(serde_json::json!({"error":"Session error"})) 233 + } 234 + } 235 + } else { 236 + HttpResponse::BadRequest().json(serde_json::json!({"error":"Invalid status URI"})) 237 + } 238 + } 239 + None => HttpResponse::Unauthorized().json(serde_json::json!({"error":"Not authenticated"})), 240 + } 241 + } 242 + 243 + /// Hide/unhide a status (admin only) 244 + #[post("/admin/hide-status")] 245 + pub async fn hide_status( 246 + session: Session, 247 + db_pool: web::Data<Arc<Pool>>, 248 + req: web::Json<HideStatusRequest>, 249 + ) -> HttpResponse { 250 + match session.get::<String>("did").unwrap_or(None) { 251 + Some(did_string) => { 252 + if did_string != crate::api::status_util::ADMIN_DID { 253 + return HttpResponse::Forbidden() 254 + .json(serde_json::json!({"error":"Admin access required"})); 255 + } 256 + let uri = req.uri.clone(); 257 + let hidden = req.hidden; 258 + let result = db_pool 259 + .conn(move |conn| { 260 + conn.execute( 261 + "UPDATE status SET hidden = ?1 WHERE uri = ?2", 262 + rusqlite::params![hidden, uri], 263 + ) 264 + }) 265 + .await; 266 + match result { 267 + Ok(rows_affected) if rows_affected > 0 => HttpResponse::Ok().json(serde_json::json!({"success":true,"message": if hidden {"Status hidden"} else {"Status unhidden"}})), 268 + Ok(_) => HttpResponse::NotFound().json(serde_json::json!({"error":"Status not found"})), 269 + Err(err) => { log::error!("Error updating hidden status: {}", err); HttpResponse::InternalServerError().json(serde_json::json!({"error":"Database error"})) } 270 + } 271 + } 272 + None => HttpResponse::Unauthorized().json(serde_json::json!({"error":"Not authenticated"})), 273 + } 274 + } 275 + 276 + /// Creates a new status 277 + #[post("/status")] 278 + pub async fn status( 279 + request: HttpRequest, 280 + session: Session, 281 + oauth_client: web::Data<OAuthClientType>, 282 + db_pool: web::Data<Arc<Pool>>, 283 + form: web::Form<StatusForm>, 284 + rate_limiter: web::Data<RateLimiter>, 285 + ) -> Result<HttpResponse, AppError> { 286 + let client_key = RateLimiter::get_client_key(&request); 287 + if !rate_limiter.check_rate_limit(&client_key) { 288 + return Err(AppError::RateLimitExceeded); 289 + } 290 + match session.get::<String>("did").unwrap_or(None) { 291 + Some(did_string) => { 292 + let did = Did::new(did_string.clone()).expect("failed to parse did"); 293 + match oauth_client.restore(&did).await { 294 + Ok(session) => { 295 + let agent = Agent::new(session); 296 + let expires = form 297 + .expires_in 298 + .as_ref() 299 + .and_then(|exp| parse_duration(exp)) 300 + .and_then(|duration| { 301 + let expiry_time = chrono::Utc::now() + duration; 302 + Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 303 + }); 304 + let status: KnownRecord = 305 + crate::lexicons::io::zzstoatzz::status::record::RecordData { 306 + created_at: Datetime::now(), 307 + emoji: form.status.clone(), 308 + text: form.text.clone(), 309 + expires, 310 + } 311 + .into(); 312 + let create_result = agent 313 + .api 314 + .com 315 + .atproto 316 + .repo 317 + .create_record( 318 + atrium_api::com::atproto::repo::create_record::InputData { 319 + collection: "io.zzstoatzz.status.record".parse().unwrap(), 320 + repo: did.into(), 321 + rkey: None, 322 + record: status.into(), 323 + swap_commit: None, 324 + validate: None, 325 + } 326 + .into(), 327 + ) 328 + .await; 329 + match create_result { 330 + Ok(record) => { 331 + let mut status = StatusFromDb::new( 332 + record.uri.clone(), 333 + did_string, 334 + form.status.clone(), 335 + ); 336 + status.text = form.text.clone(); 337 + if let Some(exp_str) = &form.expires_in { 338 + if let Some(duration) = parse_duration(exp_str) { 339 + status.expires_at = Some(chrono::Utc::now() + duration); 340 + } 341 + } 342 + let _ = status.save(db_pool.clone()).await; 343 + { 344 + let pool = db_pool.get_ref().clone(); 345 + let s = status.clone(); 346 + tokio::spawn(async move { 347 + crate::webhooks::emit_created(pool, &s).await; 348 + }); 349 + } 350 + Ok(web::Redirect::to("/") 351 + .see_other() 352 + .respond_to(&request) 353 + .map_into_boxed_body()) 354 + } 355 + Err(err) => { 356 + log::error!("Error creating status: {err}"); 357 + Ok(HttpResponse::Ok() 358 + .body("Was an error creating the status, please check the logs.")) 359 + } 360 + } 361 + } 362 + Err(err) => { 363 + session.purge(); 364 + log::error!("Error restoring session: {err}"); 365 + Err(AppError::AuthenticationError("Session error".to_string())) 366 + } 367 + } 368 + } 369 + None => Err(AppError::AuthenticationError( 370 + "You must be logged in to create a status.".to_string(), 371 + )), 372 + } 373 + }
+234
src/api/webhooks.rs
··· 1 + use crate::{config::Config, db, error_handler::AppError}; 2 + use actix_session::Session; 3 + use actix_web::{HttpResponse, Responder, Result, delete, get, post, put, web}; 4 + use async_sqlite::Pool; 5 + use atrium_api::types::string::Did; 6 + use serde::Deserialize; 7 + use std::sync::Arc; 8 + use url::Url; 9 + 10 + #[derive(Deserialize)] 11 + pub struct CreateWebhookRequest { 12 + pub url: String, 13 + pub secret: Option<String>, 14 + pub events: Option<String>, 15 + } 16 + 17 + #[derive(Deserialize)] 18 + pub struct UpdateWebhookRequest { 19 + pub url: Option<String>, 20 + pub events: Option<String>, 21 + pub active: Option<bool>, 22 + } 23 + 24 + #[get("/api/webhooks")] 25 + pub async fn list_webhooks( 26 + session: Session, 27 + db_pool: web::Data<Arc<Pool>>, 28 + ) -> Result<impl Responder> { 29 + let did = session.get::<Did>("did")?; 30 + if let Some(did) = did { 31 + let hooks = db::get_user_webhooks(&db_pool, did.as_str()) 32 + .await 33 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 34 + let response: Vec<serde_json::Value> = hooks 35 + .into_iter() 36 + .map(|h| { 37 + serde_json::json!({ 38 + "id": h.id, 39 + "url": h.url, 40 + "events": h.events, 41 + "active": h.active, 42 + "created_at": h.created_at, 43 + "updated_at": h.updated_at, 44 + "secret_masked": h.masked_secret() 45 + }) 46 + }) 47 + .collect(); 48 + Ok(web::Json(serde_json::json!({ "webhooks": response }))) 49 + } else { 50 + Ok(web::Json( 51 + serde_json::json!({ "error": "Not authenticated" }), 52 + )) 53 + } 54 + } 55 + 56 + #[post("/api/webhooks")] 57 + pub async fn create_webhook( 58 + session: Session, 59 + db_pool: web::Data<Arc<Pool>>, 60 + app_config: web::Data<Config>, 61 + payload: web::Json<CreateWebhookRequest>, 62 + ) -> Result<impl Responder> { 63 + let did = session.get::<Did>("did")?; 64 + if let Some(did) = did { 65 + // Robust URL + SSRF validation 66 + if let Err(msg) = validate_url(&payload.url, &app_config) { 67 + return Ok(web::Json(serde_json::json!({ "error": msg }))); 68 + } 69 + // Events validation 70 + if let Some(events_str) = &payload.events { 71 + if let Err(msg) = validate_events(events_str) { 72 + return Ok(web::Json(serde_json::json!({ "error": msg }))); 73 + } 74 + } 75 + let (id, secret) = db::create_webhook( 76 + &db_pool, 77 + did.as_str(), 78 + &payload.url, 79 + payload.secret.as_deref(), 80 + payload.events.as_deref(), 81 + ) 82 + .await 83 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 84 + 85 + Ok(web::Json(serde_json::json!({ 86 + "id": id, 87 + "secret": secret, // Only returned once on creation 88 + }))) 89 + } else { 90 + Ok(web::Json( 91 + serde_json::json!({ "error": "Not authenticated" }), 92 + )) 93 + } 94 + } 95 + 96 + #[put("/api/webhooks/{id}")] 97 + pub async fn update_webhook( 98 + session: Session, 99 + db_pool: web::Data<Arc<Pool>>, 100 + path: web::Path<i64>, 101 + payload: web::Json<UpdateWebhookRequest>, 102 + app_config: web::Data<Config>, 103 + ) -> impl Responder { 104 + match session.get::<Did>("did").unwrap_or(None) { 105 + Some(did) => { 106 + let id = path.into_inner(); 107 + if let Some(url) = &payload.url { 108 + if let Err(msg) = validate_url(url, &app_config) { 109 + return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); 110 + } 111 + } 112 + if let Some(events_str) = &payload.events { 113 + if let Err(msg) = validate_events(events_str) { 114 + return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); 115 + } 116 + } 117 + let res = db::update_webhook( 118 + &db_pool, 119 + did.as_str(), 120 + id, 121 + payload.url.as_deref(), 122 + payload.events.as_deref(), 123 + payload.active, 124 + ) 125 + .await; 126 + match res { 127 + Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), 128 + Err(e) => HttpResponse::InternalServerError() 129 + .json(serde_json::json!({ "error": e.to_string() })), 130 + } 131 + } 132 + None => { 133 + HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 134 + } 135 + } 136 + } 137 + 138 + fn validate_events(s: &str) -> Result<(), &'static str> { 139 + if s.trim().is_empty() { 140 + return Ok(()); 141 + } 142 + const ALLOWED: &[&str] = &["status.created", "status.deleted"]; 143 + for ev in s.split(',').map(|e| e.trim()) { 144 + if !ALLOWED.contains(&ev) { 145 + return Err("Unsupported event type"); 146 + } 147 + } 148 + Ok(()) 149 + } 150 + 151 + fn validate_url(raw: &str, cfg: &Config) -> Result<(), &'static str> { 152 + let url = Url::parse(raw).map_err(|_| "Invalid URL")?; 153 + let scheme = url.scheme(); 154 + let host = url.host_str().ok_or("Missing host")?.to_ascii_lowercase(); 155 + 156 + // Treat localhost explicitly 157 + let host_is_localname = host == "localhost"; 158 + 159 + // If host is an IP literal, apply standard library checks 160 + let ip_check_blocks = if let Ok(ip) = host.parse::<std::net::IpAddr>() { 161 + match ip { 162 + std::net::IpAddr::V4(v4) => { 163 + v4.is_private() 164 + || v4.is_loopback() 165 + || v4.is_link_local() 166 + || v4.is_multicast() 167 + || v4.is_unspecified() 168 + } 169 + std::net::IpAddr::V6(v6) => { 170 + v6.is_unique_local() || v6.is_loopback() || v6.is_multicast() || v6.is_unspecified() 171 + } 172 + } 173 + } else { 174 + false 175 + }; 176 + 177 + // Enforce HTTPS in production 178 + let is_production = !cfg.oauth_redirect_base.starts_with("http://localhost") 179 + && !cfg.oauth_redirect_base.starts_with("http://127.0.0.1"); 180 + if is_production && scheme != "https" { 181 + return Err("HTTPS required in production"); 182 + } 183 + 184 + // Basic SSRF protection in production 185 + if (host_is_localname || ip_check_blocks) && is_production { 186 + return Err("Private/local hosts not allowed"); 187 + } 188 + 189 + Ok(()) 190 + } 191 + 192 + #[post("/api/webhooks/{id}/rotate")] 193 + pub async fn rotate_secret( 194 + session: Session, 195 + db_pool: web::Data<Arc<Pool>>, 196 + path: web::Path<i64>, 197 + ) -> impl Responder { 198 + match session.get::<Did>("did").unwrap_or(None) { 199 + Some(did) => { 200 + let id = path.into_inner(); 201 + match db::rotate_webhook_secret(&db_pool, did.as_str(), id).await { 202 + Ok(new_secret) => { 203 + HttpResponse::Ok().json(serde_json::json!({ "secret": new_secret })) 204 + } 205 + Err(e) => HttpResponse::InternalServerError() 206 + .json(serde_json::json!({ "error": e.to_string() })), 207 + } 208 + } 209 + None => { 210 + HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 211 + } 212 + } 213 + } 214 + 215 + #[delete("/api/webhooks/{id}")] 216 + pub async fn delete_webhook( 217 + session: Session, 218 + db_pool: web::Data<Arc<Pool>>, 219 + path: web::Path<i64>, 220 + ) -> impl Responder { 221 + match session.get::<Did>("did").unwrap_or(None) { 222 + Some(did) => { 223 + let id = path.into_inner(); 224 + match db::delete_webhook(&db_pool, did.as_str(), id).await { 225 + Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), 226 + Err(e) => HttpResponse::InternalServerError() 227 + .json(serde_json::json!({ "error": e.to_string() })), 228 + } 229 + } 230 + None => { 231 + HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 232 + } 233 + } 234 + }
+28
src/db/mod.rs
··· 1 1 pub mod models; 2 2 pub mod queries; 3 + pub mod webhooks; 3 4 4 5 pub use models::{AuthSession, AuthState, StatusFromDb}; 5 6 pub use queries::{get_frequent_emojis, get_user_preferences, save_user_preferences}; 7 + pub use webhooks::{ 8 + Webhook, create_webhook, delete_webhook, get_user_webhooks, rotate_webhook_secret, 9 + update_webhook, 10 + }; 6 11 7 12 use async_sqlite::Pool; 8 13 ··· 54 59 accent_color TEXT DEFAULT '#1DA1F2', 55 60 updated_at INTEGER NOT NULL 56 61 )", 62 + [], 63 + ) 64 + .unwrap(); 65 + 66 + // webhooks 67 + conn.execute( 68 + "CREATE TABLE IF NOT EXISTS webhooks ( 69 + id INTEGER PRIMARY KEY AUTOINCREMENT, 70 + did TEXT NOT NULL, 71 + url TEXT NOT NULL, 72 + secret TEXT NOT NULL, 73 + events TEXT DEFAULT '*', 74 + active BOOLEAN DEFAULT TRUE, 75 + created_at INTEGER NOT NULL, 76 + updated_at INTEGER NOT NULL 77 + )", 78 + [], 79 + ) 80 + .unwrap(); 81 + 82 + // index for fast lookups by did 83 + conn.execute( 84 + "CREATE INDEX IF NOT EXISTS idx_webhooks_did ON webhooks(did)", 57 85 [], 58 86 ) 59 87 .unwrap();
+2
src/db/models.rs
··· 150 150 } 151 151 152 152 /// Loads the last 10 statuses we have saved 153 + #[allow(dead_code)] 153 154 pub async fn load_latest_statuses( 154 155 pool: &Data<Arc<Pool>>, 155 156 ) -> Result<Vec<Self>, async_sqlite::Error> { ··· 171 172 } 172 173 173 174 /// Loads paginated statuses for infinite scrolling 175 + #[allow(dead_code)] 174 176 pub async fn load_statuses_paginated( 175 177 pool: &Data<Arc<Pool>>, 176 178 offset: i32,
+189
src/db/webhooks.rs
··· 1 + use async_sqlite::Pool; 2 + use rand::{Rng, distributions::Alphanumeric}; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + pub struct Webhook { 7 + pub id: i64, 8 + pub did: String, 9 + pub url: String, 10 + pub secret: String, 11 + pub events: String, // comma-separated or "*" 12 + pub active: bool, 13 + pub created_at: i64, 14 + pub updated_at: i64, 15 + } 16 + 17 + impl Webhook { 18 + fn now() -> i64 { 19 + std::time::SystemTime::now() 20 + .duration_since(std::time::UNIX_EPOCH) 21 + .unwrap() 22 + .as_secs() as i64 23 + } 24 + 25 + pub fn masked_secret(&self) -> String { 26 + let len = self.secret.len(); 27 + if len <= 4 { 28 + return "****".to_string(); 29 + } 30 + let suffix = &self.secret[len - 4..]; 31 + format!("****{}", suffix) 32 + } 33 + } 34 + 35 + pub fn generate_secret() -> String { 36 + rand::thread_rng() 37 + .sample_iter(&Alphanumeric) 38 + .take(40) 39 + .map(char::from) 40 + .collect() 41 + } 42 + 43 + pub async fn get_user_webhooks( 44 + pool: &Pool, 45 + did: &str, 46 + ) -> Result<Vec<Webhook>, async_sqlite::Error> { 47 + let did = did.to_string(); 48 + pool.conn(move |conn| { 49 + let mut stmt = conn.prepare( 50 + "SELECT id, did, url, secret, events, COALESCE(active, 1), created_at, updated_at FROM webhooks WHERE did = ?1 ORDER BY id DESC", 51 + )?; 52 + let iter = stmt.query_map([&did], |row| { 53 + Ok(Webhook { 54 + id: row.get(0)?, 55 + did: row.get(1)?, 56 + url: row.get(2)?, 57 + secret: row.get(3)?, 58 + events: row.get(4)?, 59 + active: row.get::<_, Option<bool>>(5)?.unwrap_or(true), 60 + created_at: row.get(6)?, 61 + updated_at: row.get(7)?, 62 + }) 63 + })?; 64 + let mut v = Vec::new(); 65 + for item in iter { 66 + v.push(item?); 67 + } 68 + Ok(v) 69 + }) 70 + .await 71 + } 72 + 73 + pub async fn create_webhook( 74 + pool: &Pool, 75 + did: &str, 76 + url: &str, 77 + secret_opt: Option<&str>, 78 + events: Option<&str>, 79 + ) -> Result<(i64, String), async_sqlite::Error> { 80 + let secret = secret_opt.unwrap_or(&generate_secret()).to_string(); 81 + let now = Webhook::now(); 82 + let did_owned = did.to_string(); 83 + let url_owned = url.to_string(); 84 + let events_owned = events.unwrap_or("*").to_string(); 85 + let secret_for_insert = secret.clone(); 86 + 87 + let id = pool 88 + .conn(move |conn| { 89 + conn.execute( 90 + "INSERT INTO webhooks (did, url, secret, events, active, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6)", 91 + (&did_owned, &url_owned, &secret_for_insert, &events_owned, now, now), 92 + )?; 93 + Ok(conn.last_insert_rowid()) 94 + }) 95 + .await?; 96 + Ok((id, secret)) 97 + } 98 + 99 + pub async fn update_webhook( 100 + pool: &Pool, 101 + did: &str, 102 + id: i64, 103 + url: Option<&str>, 104 + events: Option<&str>, 105 + active: Option<bool>, 106 + ) -> Result<(), async_sqlite::Error> { 107 + let now = Webhook::now(); 108 + let did_owned = did.to_string(); 109 + let url_owned = url.map(|s| s.to_string()); 110 + let events_owned = events.map(|s| s.to_string()); 111 + pool.conn(move |conn| { 112 + // Ensure ownership 113 + let mut check = conn.prepare("SELECT COUNT(*) FROM webhooks WHERE id = ?1 AND did = ?2")?; 114 + let count: i64 = check.query_row((id, &did_owned), |row| row.get(0))?; 115 + if count == 0 { 116 + return Ok(0); 117 + } 118 + 119 + // Build dynamic update 120 + let mut fields = Vec::new(); 121 + if url_owned.is_some() { 122 + fields.push("url = ?"); 123 + } 124 + if events_owned.is_some() { 125 + fields.push("events = ?"); 126 + } 127 + if active.is_some() { 128 + fields.push("active = ?"); 129 + } 130 + fields.push("updated_at = ?"); 131 + let sql = format!( 132 + "UPDATE webhooks SET {} WHERE id = ? AND did = ?", 133 + fields.join(", ") 134 + ); 135 + 136 + let mut stmt = conn.prepare(&sql)?; 137 + let mut params: Vec<Box<dyn async_sqlite::rusqlite::ToSql>> = Vec::new(); 138 + if let Some(u) = url_owned { 139 + params.push(Box::new(u)); 140 + } 141 + if let Some(e) = events_owned { 142 + params.push(Box::new(e)); 143 + } 144 + if let Some(a) = active { 145 + params.push(Box::new(a)); 146 + } 147 + params.push(Box::new(now)); 148 + params.push(Box::new(id)); 149 + params.push(Box::new(did_owned)); 150 + 151 + let params_ref: Vec<&dyn async_sqlite::rusqlite::ToSql> = 152 + params.iter().map(|b| &**b).collect(); 153 + let _ = stmt.execute(params_ref.as_slice())?; 154 + Ok(1) 155 + }) 156 + .await?; 157 + Ok(()) 158 + } 159 + 160 + pub async fn rotate_webhook_secret( 161 + pool: &Pool, 162 + did: &str, 163 + id: i64, 164 + ) -> Result<String, async_sqlite::Error> { 165 + let new_secret = generate_secret(); 166 + let now = Webhook::now(); 167 + let did_owned = did.to_string(); 168 + let new_for_update = new_secret.clone(); 169 + pool.conn(move |conn| { 170 + let mut stmt = conn.prepare( 171 + "UPDATE webhooks SET secret = ?1, updated_at = ?2 WHERE id = ?3 AND did = ?4", 172 + )?; 173 + let _ = stmt.execute((&new_for_update, now, id, &did_owned))?; 174 + Ok(()) 175 + }) 176 + .await?; 177 + Ok(new_secret) 178 + } 179 + 180 + pub async fn delete_webhook(pool: &Pool, did: &str, id: i64) -> Result<(), async_sqlite::Error> { 181 + let did_owned = did.to_string(); 182 + pool.conn(move |conn| { 183 + let mut stmt = conn.prepare("DELETE FROM webhooks WHERE id = ?1 AND did = ?2")?; 184 + let _ = stmt.execute((id, &did_owned))?; 185 + Ok(()) 186 + }) 187 + .await?; 188 + Ok(()) 189 + }
+2
src/dev_utils.rs
··· 3 3 use rand::prelude::*; 4 4 5 5 /// Generate dummy status data for development testing 6 + #[allow(dead_code)] 6 7 pub fn generate_dummy_statuses(count: usize) -> Vec<StatusFromDb> { 7 8 let mut rng = thread_rng(); 8 9 let mut statuses = Vec::new(); ··· 82 83 } 83 84 84 85 /// Check if dev mode is requested via query parameter 86 + #[allow(dead_code)] 85 87 pub fn is_dev_mode_requested(query: &str) -> bool { 86 88 query.contains("dev=true") || query.contains("dev=1") 87 89 }
+3
src/emoji.rs
··· 58 58 } 59 59 } 60 60 61 + #[allow(dead_code)] 61 62 static BUILTIN_SLUGS: OnceCell<Arc<HashSet<String>>> = OnceCell::new(); 62 63 64 + #[allow(dead_code)] 63 65 async fn load_builtin_slugs_inner() -> Arc<HashSet<String>> { 64 66 // Fetch emoji data and collect first short_name as slug 65 67 let url = "https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json"; ··· 98 100 Arc::new(set) 99 101 } 100 102 103 + #[allow(dead_code)] 101 104 pub async fn is_builtin_slug(name: &str) -> bool { 102 105 let name = name.to_lowercase(); 103 106 if let Some(cache) = BUILTIN_SLUGS.get() {
+2 -1
src/main.rs
··· 40 40 mod resolver; 41 41 mod storage; 42 42 mod templates; 43 + mod webhooks; 43 44 44 45 #[actix_web::main] 45 46 async fn main() -> std::io::Result<()> { ··· 230 231 #[cfg(test)] 231 232 mod tests { 232 233 use super::*; 233 - use crate::api::status::get_custom_emojis; 234 + use crate::api::status_read::get_custom_emojis; 234 235 use actix_web::{App, test}; 235 236 236 237 #[actix_web::test]
+183
src/webhooks.rs
··· 1 + use async_sqlite::Pool; 2 + use hmac::{Hmac, Mac}; 3 + use once_cell::sync::Lazy; 4 + use reqwest::Client; 5 + use serde::Serialize; 6 + use sha2::Sha256; 7 + 8 + use crate::db::{StatusFromDb, Webhook, get_user_webhooks}; 9 + use futures_util::future; 10 + 11 + #[derive(Serialize)] 12 + pub struct StatusEvent<'a> { 13 + pub event: &'a str, // "status.created" | "status.deleted" | "status.cleared" 14 + pub did: &'a str, 15 + pub handle: Option<&'a str>, 16 + pub status: Option<&'a str>, 17 + pub text: Option<&'a str>, 18 + pub uri: Option<&'a str>, 19 + pub since: Option<&'a str>, 20 + pub expires: Option<&'a str>, 21 + } 22 + 23 + fn should_send(h: &Webhook, event: &str) -> bool { 24 + if !h.active { 25 + return false; 26 + } 27 + let events = h.events.trim(); 28 + if events == "*" || events.is_empty() { 29 + return true; 30 + } 31 + events 32 + .split(',') 33 + .map(|e| e.trim()) 34 + .any(|e| e.eq_ignore_ascii_case(event)) 35 + } 36 + 37 + fn hmac_sig_hex(secret: &str, ts: &str, payload: &[u8]) -> String { 38 + let mut mac = 39 + Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); 40 + mac.update(ts.as_bytes()); 41 + mac.update(b"."); 42 + mac.update(payload); 43 + hex::encode(mac.finalize().into_bytes()) 44 + } 45 + 46 + static HTTP: Lazy<Client> = Lazy::new(Client::new); 47 + 48 + pub async fn send_status_event(pool: std::sync::Arc<Pool>, did: &str, event: StatusEvent<'_>) { 49 + let hooks = match get_user_webhooks(&pool, did).await { 50 + Ok(h) => h, 51 + Err(e) => { 52 + log::error!("webhooks: failed to load webhooks for {}: {}", did, e); 53 + return; 54 + } 55 + }; 56 + let payload = match serde_json::to_vec(&event) { 57 + Ok(p) => p, 58 + Err(e) => { 59 + log::error!("webhooks: failed to serialize payload: {}", e); 60 + return; 61 + } 62 + }; 63 + let ts = chrono::Utc::now().timestamp().to_string(); 64 + 65 + let futures = hooks 66 + .into_iter() 67 + .filter(|h| should_send(h, event.event)) 68 + .map(|h| { 69 + let payload = payload.clone(); 70 + let ts = ts.clone(); 71 + let client = HTTP.clone(); 72 + async move { 73 + let sig = hmac_sig_hex(&h.secret, &ts, &payload); 74 + let res = client 75 + .post(&h.url) 76 + .header("User-Agent", "status-webhooks/1.0") 77 + .header("Content-Type", "application/json") 78 + .header("X-Status-Webhook-Timestamp", &ts) 79 + .header("X-Status-Webhook-Signature", format!("sha256={}", sig)) 80 + .timeout(std::time::Duration::from_secs(5)) 81 + .body(payload) 82 + .send() 83 + .await; 84 + 85 + match res { 86 + Ok(resp) => { 87 + if !resp.status().is_success() { 88 + let status = resp.status(); 89 + let body = resp.text().await.unwrap_or_default(); 90 + log::warn!( 91 + "webhook delivery failed: {} -> {} body={}", 92 + &h.url, 93 + status, 94 + body 95 + ); 96 + } 97 + } 98 + Err(e) => log::warn!("webhook delivery error to {}: {}", &h.url, e), 99 + } 100 + } 101 + }); 102 + 103 + future::join_all(futures).await; 104 + } 105 + 106 + pub async fn emit_created(pool: std::sync::Arc<Pool>, s: &StatusFromDb) { 107 + let did = s.author_did.clone(); 108 + let emoji = s.status.clone(); 109 + let text = s.text.clone(); 110 + let uri = s.uri.clone(); 111 + let since = s.started_at.to_rfc3339(); 112 + let expires = s.expires_at.map(|e| e.to_rfc3339()); 113 + let event = StatusEvent { 114 + event: "status.created", 115 + did: &did, 116 + handle: None, 117 + status: Some(&emoji), 118 + text: text.as_deref(), 119 + uri: Some(&uri), 120 + since: Some(&since), 121 + expires: expires.as_deref(), 122 + }; 123 + send_status_event(pool, &did, event).await; 124 + } 125 + 126 + pub async fn emit_deleted(pool: std::sync::Arc<Pool>, did: &str, uri: &str) { 127 + let did_owned = did.to_string(); 128 + let uri_owned = uri.to_string(); 129 + let event = StatusEvent { 130 + event: "status.deleted", 131 + did: &did_owned, 132 + handle: None, 133 + status: None, 134 + text: None, 135 + uri: Some(&uri_owned), 136 + since: None, 137 + expires: None, 138 + }; 139 + send_status_event(pool, &did_owned, event).await; 140 + } 141 + 142 + #[cfg(test)] 143 + mod tests { 144 + use super::*; 145 + 146 + #[test] 147 + fn test_should_send_wildcard() { 148 + let h = Webhook { 149 + id: 1, 150 + did: "d".into(), 151 + url: "u".into(), 152 + secret: "s".into(), 153 + events: "*".into(), 154 + active: true, 155 + created_at: 0, 156 + updated_at: 0, 157 + }; 158 + assert!(should_send(&h, "status.created")); 159 + } 160 + 161 + #[test] 162 + fn test_should_send_specific() { 163 + let h = Webhook { 164 + id: 1, 165 + did: "d".into(), 166 + url: "u".into(), 167 + secret: "s".into(), 168 + events: "status.deleted".into(), 169 + active: true, 170 + created_at: 0, 171 + updated_at: 0, 172 + }; 173 + assert!(should_send(&h, "status.deleted")); 174 + assert!(!should_send(&h, "status.created")); 175 + } 176 + 177 + #[test] 178 + fn test_hmac_sig_hex() { 179 + let sig = hmac_sig_hex("secret", "1234567890", b"{\"a\":1}"); 180 + // Deterministic expected if inputs fixed 181 + assert_eq!(sig.len(), 64); 182 + } 183 + }
+12
static/webhook_guide.css
··· 1 + .wh-tabs { display: flex; gap: 8px; margin: 10px 0; } 2 + .wh-dynamic .wh-tabs { justify-content: flex-end; } 3 + .wh-tabs button { border: 1px solid var(--border-color, #2a2a2a); background: var(--bg-secondary, #0f0f0f); color: var(--text, #fff); padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; } 4 + .wh-tabs button.active { background: var(--accent, #1DA1F2); color: #000; border-color: var(--accent, #1DA1F2); } 5 + .wh-snippet { display: none; } 6 + .wh-snippet.active { display: block; } 7 + .wh-static h4 { margin: 0 0 6px 0; } 8 + .wh-static ul { margin: 0 0 8px 18px; padding: 0; } 9 + .wh-static pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; } 10 + @media (max-width: 900px) { 11 + .wh-dynamic .wh-tabs { justify-content: flex-start; } 12 + }
+13
static/webhook_guide.js
··· 1 + document.addEventListener('DOMContentLoaded', () => { 2 + const tabs = document.querySelectorAll('#wh-lang-tabs [data-lang]'); 3 + const blocks = document.querySelectorAll('.wh-snippet[data-lang]'); 4 + if (!tabs.length || !blocks.length) return; 5 + const activate = (lang) => { 6 + tabs.forEach(t => t.classList.toggle('active', t.dataset.lang === lang)); 7 + blocks.forEach(b => b.classList.toggle('active', b.dataset.lang === lang)); 8 + }; 9 + tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.lang))); 10 + // default 11 + activate('node'); 12 + }); 13 +
+6 -1
templates/feed.html
··· 965 965 966 966 try { 967 967 const response = await fetch(`/api/feed?offset=${offset}&limit=20`); 968 - const newStatuses = await response.json(); 968 + const data = await response.json(); 969 + const newStatuses = Array.isArray(data) ? data : (data.statuses || []); 969 970 970 971 if (newStatuses.length === 0) { 971 972 hasMore = false; ··· 1036 1037 } 1037 1038 1038 1039 offset += newStatuses.length; 1040 + if (!Array.isArray(data) && typeof data === 'object') { 1041 + if (typeof data.next_offset === 'number') offset = data.next_offset; 1042 + if (typeof data.has_more === 'boolean') hasMore = data.has_more; 1043 + } 1039 1044 loadingIndicator.style.display = 'none'; 1040 1045 } catch (error) { 1041 1046 console.error('Error loading more statuses:', error);
+377 -2
templates/status.html
··· 89 89 <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 90 90 </div> 91 91 </div> 92 + <div class="settings-row"> 93 + <label>integrations</label> 94 + <button id="open-webhook-config" class="nav-button">configure webhooks</button> 95 + </div> 92 96 </div> 93 97 {% endif %} 94 98 ··· 234 238 <a href="/logout" class="logout-link">log out</a> 235 239 </div> 236 240 {% endif %} 241 + 242 + <!-- Webhook Full-Page Modal --> 243 + <div id="webhook-modal" class="webhook-modal hidden" aria-hidden="true"> 244 + <div class="webhook-modal-content"> 245 + <div class="webhook-modal-header"> 246 + <h2>webhooks</h2> 247 + <button id="close-webhook-modal" aria-label="Close">โœ•</button> 248 + </div> 249 + <div class="webhook-modal-body"> 250 + <div class="webhook-intro" style="margin-bottom:10px;"> 251 + <p>Send signed events when your status changes. Configure a URL that accepts JSON POSTs. We include an HMAC-SHA256 signature in <code>X-Status-Webhook-Signature</code> and a UNIX timestamp in <code>X-Status-Webhook-Timestamp</code>.</p> 252 + </div> 253 + <form id="create-webhook-form" class="webhook-form" aria-label="create webhook"> 254 + <div style="display:flex; flex-direction:column; gap:4px;"> 255 + <input type="url" id="wh-url" placeholder="Webhook URL (https://example.com/webhook)" required /> 256 + <div class="field-help">HTTPS required in production. Local/private hosts allowed only in local dev.</div> 257 + </div> 258 + <div style="display:flex; flex-direction:column; gap:4px;"> 259 + <input type="text" id="wh-secret" placeholder="Secret (optional โ€“ autogenerated)" /> 260 + <div class="field-help">Used to sign requests with HMAC-SHA256. Reveal only on creation/rotation.</div> 261 + </div> 262 + <div style="display:flex; flex-direction:column; gap:4px;"> 263 + <input type="text" id="wh-events" placeholder="Events (optional, default *) e.g. status.created,status.deleted" /> 264 + <div class="field-help">Comma-separated. Supported: <code>status.created</code>, <code>status.deleted</code> or <code>*</code>.</div> 265 + </div> 266 + <button type="submit" aria-label="add webhook">add webhook</button> 267 + <div class="field-help">You can add multiple webhooks. Toggle active, rotate secrets, or delete below.</div> 268 + </form> 269 + <div id="webhook-list" class="webhook-list" aria-live="polite"></div> 270 + 271 + <details class="wh-guide" id="webhook-guide"> 272 + <summary>Integration guide</summary> 273 + <div class="content"> 274 + <div class="wh-grid"> 275 + <div class="wh-static"> 276 + <h4>Request</h4> 277 + <ul> 278 + <li>Method: POST</li> 279 + <li>Content-Type: application/json</li> 280 + <li>Header <code>X-Status-Webhook-Timestamp</code>: UNIX seconds</li> 281 + <li>Header <code>X-Status-Webhook-Signature</code>: <code>sha256=&lt;hex&gt;</code></li> 282 + </ul> 283 + <h4>Payload</h4> 284 + <pre><code>{ 285 + "event": "status.created", // or "status.deleted" 286 + "did": "did:plc:...", 287 + "handle": null, 288 + "status": "๐Ÿ™‚", // created only 289 + "text": "in a meeting", // optional 290 + "uri": "at://...", // record URI 291 + "since": "2025-09-10T16:00:00Z", // created only 292 + "expires": null // created only 293 + }</code></pre> 294 + </div> 295 + <div class="wh-dynamic"> 296 + <div id="wh-lang-tabs" class="wh-tabs" role="tablist" aria-label="language selector"> 297 + <button type="button" data-lang="node" role="tab" aria-selected="true">Node</button> 298 + <button type="button" data-lang="rust" role="tab">Rust</button> 299 + <button type="button" data-lang="python" role="tab">Python</button> 300 + <button type="button" data-lang="go" role="tab">Go</button> 301 + </div> 302 + <div class="wh-snippet" data-lang="node"> 303 + <h4>Verify signature</h4> 304 + <p>Compute HMAC-SHA256 over <code>timestamp + "." + rawBody</code> using your secret. Compare to header (without the <code>sha256=</code> prefix) with constant-time equality, and reject if timestamp is too old (e.g., &gt; 5 minutes).</p> 305 + <pre><code>// Node (TypeScript) 306 + import crypto from 'node:crypto'; 307 + 308 + function verify(req: any, rawBody: Buffer, secret: string): boolean { 309 + const ts = req.headers['x-status-webhook-timestamp']; 310 + const sig = String(req.headers['x-status-webhook-signature'] || '').replace(/^sha256=/, ''); 311 + if (!ts || !sig) return false; 312 + const now = Math.floor(Date.now()/1000); 313 + if (Math.abs(now - Number(ts)) > 300) return false; // 5m 314 + const mac = crypto.createHmac('sha256', secret).update(String(ts)).update('.').update(rawBody).digest('hex'); 315 + return crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(sig, 'hex')); 316 + } 317 + </code></pre> 318 + </div> 319 + <div class="wh-snippet" data-lang="rust"> 320 + <pre><code>// Rust (axum-ish) 321 + use hmac::{Hmac, Mac}; 322 + use sha2::Sha256; 323 + 324 + fn verify(ts: &str, sig_hdr: &str, body: &[u8], secret: &str) -> bool { 325 + let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr); 326 + if let Ok(ts_int) = ts.parse::<i64>() { 327 + if (chrono::Utc::now().timestamp() - ts_int).abs() > 300 { return false; } 328 + } else { return false; } 329 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); 330 + mac.update(ts.as_bytes()); 331 + mac.update(b"."); 332 + mac.update(body); 333 + let calc = hex::encode(mac.finalize().into_bytes()); 334 + subtle::ConstantTimeEq::ct_eq(calc.as_bytes(), sig.as_bytes()).into() 335 + } 336 + </code></pre> 337 + </div> 338 + <div class="wh-snippet" data-lang="python"> 339 + <pre><code># Python (Flask example) 340 + import hmac, hashlib, time 341 + from flask import request 342 + 343 + def verify(secret: str, raw_body: bytes) -> bool: 344 + ts = request.headers.get('X-Status-Webhook-Timestamp') 345 + sig_hdr = request.headers.get('X-Status-Webhook-Signature', '') 346 + if not ts or not sig_hdr.startswith('sha256='): 347 + return False 348 + if abs(int(time.time()) - int(ts)) > 300: 349 + return False 350 + expected = hmac.new(secret.encode(), (ts + '.').encode() + raw_body, hashlib.sha256).hexdigest() 351 + actual = sig_hdr[len('sha256='):] 352 + return hmac.compare_digest(expected, actual) 353 + </code></pre> 354 + </div> 355 + <div class="wh-snippet" data-lang="go"> 356 + <pre><code>// Go (net/http) 357 + package main 358 + 359 + import ( 360 + "crypto/hmac" 361 + "crypto/sha256" 362 + "encoding/hex" 363 + "net/http" 364 + "strconv" 365 + "time" 366 + ) 367 + 368 + func verify(r *http.Request, body []byte, secret string) bool { 369 + ts := r.Header.Get("X-Status-Webhook-Timestamp") 370 + sig := r.Header.Get("X-Status-Webhook-Signature") 371 + if ts == "" || sig == "" { return false } 372 + if len(sig) > 7 && sig[:7] == "sha256=" { sig = sig[7:] } 373 + tsv, err := strconv.ParseInt(ts, 10, 64) 374 + if err != nil || time.Now().Unix()-tsv > 300 || tsv-time.Now().Unix() > 300 { return false } 375 + mac := hmac.New(sha256.New, []byte(secret)) 376 + mac.Write([]byte(ts)) 377 + mac.Write([]byte(".")) 378 + mac.Write(body) 379 + expected := hex.EncodeToString(mac.Sum(nil)) 380 + return hmac.Equal([]byte(expected), []byte(sig)) 381 + } 382 + </code></pre> 383 + </div> 384 + </div> 385 + </div> 386 + </div> 387 + </details> 388 + <link rel="stylesheet" href="/static/webhook_guide.css"> 389 + <script src="/static/webhook_guide.js"></script> 390 + </div> 391 + </div> 392 + </div> 237 393 238 394 <!-- History --> 239 395 {% if !history.is_empty() %} ··· 1311 1467 border-bottom: none; 1312 1468 } 1313 1469 } 1470 + /* Webhook Modal */ 1471 + .webhook-modal.hidden { display: none; } 1472 + .webhook-modal { 1473 + position: fixed; 1474 + inset: 0; 1475 + background: rgba(0,0,0,0.6); 1476 + z-index: 1000; 1477 + display: flex; 1478 + align-items: center; 1479 + justify-content: center; 1480 + } 1481 + .webhook-modal-content { 1482 + width: 96vw; 1483 + height: 92vh; 1484 + max-width: 1400px; 1485 + max-height: 92vh; 1486 + background: var(--bg, #111); 1487 + color: var(--text, #fff); 1488 + border: 1px solid var(--border-color, #2a2a2a); 1489 + border-radius: 12px; 1490 + display: flex; 1491 + flex-direction: column; 1492 + } 1493 + .webhook-modal-header { 1494 + display: flex; 1495 + align-items: center; 1496 + justify-content: space-between; 1497 + padding: 16px 20px; 1498 + border-bottom: 1px solid var(--border-color, #2a2a2a); 1499 + } 1500 + .webhook-modal-header h2 { margin: 0; font-size: 20px; } 1501 + .webhook-modal-header button { 1502 + background: transparent; 1503 + color: inherit; 1504 + border: 1px solid var(--border-color, #2a2a2a); 1505 + border-radius: 8px; 1506 + padding: 6px 10px; 1507 + cursor: pointer; 1508 + } 1509 + .webhook-modal-body { 1510 + padding: 16px 20px; 1511 + overflow: auto; 1512 + height: calc(92vh - 60px); 1513 + } 1514 + .webhook-form { 1515 + display: grid; 1516 + grid-template-columns: 1.2fr 0.8fr 1fr auto; 1517 + gap: 8px; 1518 + margin-bottom: 16px; 1519 + } 1520 + .webhook-form input { 1521 + background: var(--bg-secondary, #0d0d0d); 1522 + color: var(--text, #fff); 1523 + border: 1px solid var(--border-color, #2a2a2a); 1524 + border-radius: 8px; 1525 + padding: 10px 12px; 1526 + } 1527 + .webhook-form button { 1528 + background: var(--accent, #1DA1F2); 1529 + color: #000; 1530 + border: none; 1531 + border-radius: 8px; 1532 + padding: 10px 12px; 1533 + cursor: pointer; 1534 + } 1535 + .field-help { font-size: 12px; opacity: 0.8; margin-top: 2px; grid-column: 1 / -1; } 1536 + .webhook-list .item { 1537 + border: 1px solid var(--border-color, #2a2a2a); 1538 + border-radius: 8px; 1539 + padding: 12px; 1540 + margin-bottom: 10px; 1541 + display: grid; 1542 + grid-template-columns: 1fr auto; 1543 + gap: 8px; 1544 + } 1545 + .webhook-list .meta { font-size: 12px; opacity: 0.8; } 1546 + .webhook-actions { display: flex; gap: 8px; align-items: center; } 1547 + .webhook-actions button { 1548 + background: transparent; 1549 + color: inherit; 1550 + border: 1px solid var(--border-color, #2a2a2a); 1551 + border-radius: 8px; 1552 + padding: 6px 10px; 1553 + cursor: pointer; 1554 + } 1555 + .webhook-actions .danger { border-color: #803; color: #f77; } 1556 + .webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } 1557 + .webhook-active input { transform: translateY(1px); } 1558 + 1559 + /* Collapsible guide */ 1560 + .wh-guide { 1561 + margin-top: 20px; 1562 + border: 1px solid var(--border-color, #2a2a2a); 1563 + border-radius: 10px; 1564 + overflow: hidden; 1565 + } 1566 + .wh-guide summary { 1567 + padding: 12px 14px; 1568 + cursor: pointer; 1569 + background: var(--bg-secondary, #0f0f0f); 1570 + font-weight: 600; 1571 + outline: none; 1572 + } 1573 + .wh-guide .content { padding: 14px; } 1574 + .wh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } 1575 + .wh-grid pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; } 1576 + @media (max-width: 900px) { .wh-grid { grid-template-columns: 1fr; } } 1314 1577 </style> 1315 1578 1316 1579 <script> ··· 1369 1632 // Initialize on page load 1370 1633 // Timestamp formatting is handled by /static/timestamps.js 1371 1634 1372 - document.addEventListener('DOMContentLoaded', async () => { 1635 + document.addEventListener('DOMContentLoaded', async () => { 1373 1636 initTheme(); 1374 1637 // Timestamps are auto-initialized by timestamps.js 1375 1638 ··· 1526 1789 emojiKeywords = data.emojis; 1527 1790 window.__emojiSlugs = data.slugs || {}; 1528 1791 window.__reservedEmojiNames = new Set(data.reserved || []); 1529 - 1792 + 1793 + // Webhook modal handlers (owner only) 1794 + const openWebhookBtn = document.getElementById('open-webhook-config'); 1795 + const modal = document.getElementById('webhook-modal'); 1796 + const closeModalBtn = document.getElementById('close-webhook-modal'); 1797 + const listEl = document.getElementById('webhook-list'); 1798 + const formEl = document.getElementById('create-webhook-form'); 1799 + 1800 + if (openWebhookBtn && modal && closeModalBtn && listEl && formEl) { 1801 + const fetchWebhooks = async () => { 1802 + const res = await fetch('/api/webhooks'); 1803 + if (!res.ok) return []; 1804 + const data = await res.json(); 1805 + return data.webhooks || []; 1806 + }; 1807 + 1808 + const renderWebhooks = (hooks) => { 1809 + if (!hooks.length) { 1810 + listEl.innerHTML = '<p class="meta">no webhooks configured yet</p>'; 1811 + return; 1812 + } 1813 + listEl.innerHTML = ''; 1814 + hooks.forEach(h => { 1815 + const item = document.createElement('div'); 1816 + item.className = 'item'; 1817 + item.innerHTML = ` 1818 + <div> 1819 + <div><strong>${h.url}</strong></div> 1820 + <div class="meta"> 1821 + events: ${h.events || '*'} โ€ข secret: ${h.secret_masked} โ€ข ${h.active ? 'active' : 'inactive'} 1822 + </div> 1823 + </div> 1824 + <div class="webhook-actions"> 1825 + <label class="webhook-active"><input type="checkbox" ${h.active ? 'checked' : ''} data-action="toggle" data-id="${h.id}"> active</label> 1826 + <button data-action="rotate" data-id="${h.id}">rotate secret</button> 1827 + <button class="danger" data-action="delete" data-id="${h.id}">delete</button> 1828 + </div> 1829 + `; 1830 + listEl.appendChild(item); 1831 + }); 1832 + }; 1833 + 1834 + const openModal = async () => { 1835 + modal.classList.remove('hidden'); 1836 + modal.setAttribute('aria-hidden', 'false'); 1837 + const hooks = await fetchWebhooks(); 1838 + renderWebhooks(hooks); 1839 + }; 1840 + 1841 + const closeModal = () => { 1842 + modal.classList.add('hidden'); 1843 + modal.setAttribute('aria-hidden', 'true'); 1844 + }; 1845 + 1846 + openWebhookBtn.addEventListener('click', openModal); 1847 + closeModalBtn.addEventListener('click', closeModal); 1848 + modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); 1849 + 1850 + formEl.addEventListener('submit', async (e) => { 1851 + e.preventDefault(); 1852 + const url = document.getElementById('wh-url').value.trim(); 1853 + const secret = document.getElementById('wh-secret').value.trim(); 1854 + const events = document.getElementById('wh-events').value.trim(); 1855 + const res = await fetch('/api/webhooks', { 1856 + method: 'POST', 1857 + headers: { 'Content-Type': 'application/json' }, 1858 + body: JSON.stringify({ url, secret: secret || null, events: events || null }) 1859 + }); 1860 + if (res.ok) { 1861 + const hooks = await fetchWebhooks(); 1862 + renderWebhooks(hooks); 1863 + formEl.reset(); 1864 + try { const data = await res.json(); if (data.secret) alert('Webhook created. Save this secret now: ' + data.secret); } catch {} 1865 + } else { 1866 + alert('Failed to create webhook'); 1867 + } 1868 + }); 1869 + 1870 + listEl.addEventListener('click', async (e) => { 1871 + const btn = e.target.closest('button'); 1872 + if (!btn) return; 1873 + const id = btn.dataset.id; 1874 + const action = btn.dataset.action; 1875 + if (action === 'delete') { 1876 + if (!confirm('Delete this webhook?')) return; 1877 + const res = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' }); 1878 + if (res.ok) { 1879 + const hooks = await fetchWebhooks(); 1880 + renderWebhooks(hooks); 1881 + } 1882 + } else if (action === 'rotate') { 1883 + const res = await fetch(`/api/webhooks/${id}/rotate`, { method: 'POST' }); 1884 + if (res.ok) { 1885 + const data = await res.json(); 1886 + alert('New secret: ' + data.secret + '\nSave it now.'); 1887 + const hooks = await fetchWebhooks(); 1888 + renderWebhooks(hooks); 1889 + } 1890 + } 1891 + }); 1892 + 1893 + listEl.addEventListener('change', async (e) => { 1894 + const input = e.target.closest('input[type="checkbox"][data-action="toggle"]'); 1895 + if (!input) return; 1896 + const id = input.dataset.id; 1897 + const active = !!input.checked; 1898 + await fetch(`/api/webhooks/${id}`, { 1899 + method: 'PUT', headers: { 'Content-Type': 'application/json' }, 1900 + body: JSON.stringify({ active }) 1901 + }); 1902 + }); 1903 + } 1904 + 1530 1905 // Load frequent emojis from API 1531 1906 let frequentEmojis = ['๐Ÿ˜Š', '๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐ŸŽ‰', '๐Ÿ”ฅ', 'โœจ', '๐Ÿ’ฏ', '๐Ÿš€', '๐Ÿ’ช', '๐Ÿ™', '๐Ÿ‘']; // defaults 1532 1907 try {