+10
Cargo.lock
+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
+4
Cargo.toml
+31
-22
src/api/mod.rs
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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=<hex></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., > 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 {