slack status without the slack status.zzstoatzz.io/
quickslice
at 2cab7c64b43b99b9466ccc28df5379092eb2cdd9 448 lines 16 kB view raw
1use crate::config::Config; 2use crate::db; 3use crate::resolver::HickoryDnsTxtResolver; 4use crate::{ 5 api::auth::OAuthClientType, 6 db::StatusFromDb, 7 templates::{ErrorTemplate, FeedTemplate, StatusTemplate}, 8}; 9use actix_session::Session; 10use actix_web::{Responder, Result, get, web}; 11use askama::Template; 12use async_sqlite::Pool; 13use atrium_api::types::string::Did; 14use atrium_common::resolver::Resolver; 15use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; 16use atrium_oauth::DefaultHttpClient; 17use serde_json::json; 18use std::sync::Arc; 19 20use 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("/")] 24pub 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}")] 127pub 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")] 197pub 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")] 237pub 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")] 279pub 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")] 315pub 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")] 355pub 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")] 392pub 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")] 401pub 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")] 441pub 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}