slack status without the slack
status.zzstoatzz.io/
quickslice
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}