···11-# Status App Configuration
22-# Copy this file to .env and customize as needed
33-44-# Owner handle for the default status page
55-OWNER_HANDLE=zzstoatzz.io
66-77-# Database URL (defaults to local SQLite)
88-# For production, consider using a persistent volume
99-DATABASE_URL=sqlite://./statusphere.sqlite3
1010-1111-# OAuth redirect base URL (must match your deployment URL)
1212-# For production: https://status.yourdomain.com
1313-OAUTH_REDIRECT_BASE=http://localhost:8080
1414-1515-# Server configuration
1616-SERVER_HOST=127.0.0.1
1717-SERVER_PORT=8080
1818-1919-# Enable firehose ingester (true/false)
2020-# Set to true in production to receive real-time updates
2121-ENABLE_FIREHOSE=false
2222-2323-# Log level (trace, debug, info, warn, error)
2424-RUST_LOG=info
2525-2626-# Note: Admin DID is intentionally hardcoded in the code for security
2727-# This prevents accidental exposure or modification
+20-27
src/db.rs
···1212/// Creates the tables in the db.
1313pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> {
1414 pool.conn(move |conn| {
1515- conn.execute("PRAGMA foreign_keys = ON", [])?;
1515+ conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
16161717 // status
1818 conn.execute(
···2626 indexedAt INTEGER NOT NULL
2727 )",
2828 [],
2929- )?;
2929+ )
3030+ .unwrap();
30313132 // auth_session
3233 conn.execute(
···3536 session TEXT NOT NULL
3637 )",
3738 [],
3838- )?;
3939+ )
4040+ .unwrap();
39414042 // auth_state
4143 conn.execute(
···4446 state TEXT NOT NULL
4547 )",
4648 [],
4747- )?;
4949+ )
5050+ .unwrap();
48514952 // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory
5053···5356 conn.execute(
5457 "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)",
5558 [],
5656- )?;
5959+ )
6060+ .unwrap();
57615862 // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC)
5963 conn.execute(
6064 "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)",
6165 [],
6262- )?;
6666+ )
6767+ .unwrap();
63686469 // Add hidden column for moderation (won't error if already exists)
6570 let _ = conn.execute(
···125130 indexed_at: {
126131 let timestamp: i64 = row.get(6)?;
127132 DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
128128- Error::InvalidColumnType(6, "Invalid timestamp".to_string(), Type::Text)
133133+ Error::InvalidColumnType(6, "Invalid timestamp".parse().unwrap(), Type::Text)
129134 })?
130135 },
131136 handle: None,
···221226 let mut stmt =
222227 conn.prepare("SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT 10")?;
223228 let status_iter = stmt
224224- .query_map([], |row| Self::map_from_row(row))?;
229229+ .query_map([], |row| Ok(Self::map_from_row(row).unwrap()))
230230+ .unwrap();
225231226232 let mut statuses = Vec::new();
227233 for status in status_iter {
···245251 )?;
246252 let status_iter = stmt
247253 .query_map(rusqlite::params![limit, offset], |row| {
248248- Self::map_from_row(row)
249249- })?;
254254+ Ok(Self::map_from_row(row).unwrap())
255255+ })
256256+ .unwrap();
250257251258 let mut statuses = Vec::new();
252259 for status in status_iter {
···325332 where
326333 V: Serialize,
327334 {
328328- let session = serde_json::to_string(&session)
329329- .unwrap_or_else(|e| {
330330- log::error!("Failed to serialize session: {}", e);
331331- "{}".to_string()
332332- });
335335+ let session = serde_json::to_string(&session).unwrap();
333336 Self {
334337 key: key.to_string(),
335338 session,
···345348346349 /// Gets a session by the users did(key)
347350 pub async fn get_by_did(pool: &Pool, did: String) -> Result<Option<Self>, async_sqlite::Error> {
348348- let did = match Did::new(did) {
349349- Ok(d) => d,
350350- Err(e) => {
351351- log::error!("Invalid DID: {}", e);
352352- return Ok(None);
353353- }
354354- };
351351+ let did = Did::new(did).unwrap();
355352 pool.conn(move |conn| {
356353 let mut stmt = conn.prepare("SELECT * FROM auth_session WHERE key = ?1")?;
357354 stmt.query_row([did.as_str()], Self::map_from_row)
···428425 where
429426 V: Serialize,
430427 {
431431- let state = serde_json::to_string(&state)
432432- .unwrap_or_else(|e| {
433433- log::error!("Failed to serialize state: {}", e);
434434- "{}".to_string()
435435- });
428428+ let state = serde_json::to_string(&state).unwrap();
436429 Self {
437430 key: key.to_string(),
438431 state,
+110-115
src/main.rs
···4545};
4646use templates::{ErrorTemplate, Profile};
47474848-mod config;
4948mod db;
5049mod error_handler;
5150mod ingester;
···7978/// HandleResolver to make it easier to access the OAuthClient in web requests
8079type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
81808181+/// Admin DID for moderation
8282+const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io
8383+8284/// Check if a DID is the admin
8383-fn is_admin(did: &str, config: &config::Config) -> bool {
8484- did == config.admin_did
8585+fn is_admin(did: &str) -> bool {
8686+ did == ADMIN_DID
8587}
86888789/// OAuth client metadata endpoint for production
8890#[get("/client-metadata.json")]
8989-async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> {
9090- let public_url = config.oauth_redirect_base.clone();
9191+async fn client_metadata() -> Result<HttpResponse> {
9292+ let public_url = std::env::var("PUBLIC_URL")
9393+ .unwrap_or_else(|_| "http://localhost:8080".to_string());
91949295 let metadata = serde_json::json!({
9396 "client_id": format!("{}/client-metadata.json", public_url),
···187190 let agent = Agent::new(bsky_session);
188191 match agent.did().await {
189192 Some(did) => {
190190- if let Err(e) = session.insert("did", did) {
191191- log::error!("Failed to save session: {}", e);
192192- }
193193+ session.insert("did", did).unwrap();
193194 Redirect::to("/")
194195 .see_other()
195196 .respond_to(&request)
···246247}
247248248249/// TS version https://github.com/bluesky-social/statusphere-example-app/blob/e4721616df50cd317c198f4c00a4818d5626d4ce/src/routes.ts#L101
249249-/// Shared function to resolve handles for a list of statuses
250250-async fn resolve_handles_for_statuses(
251251- statuses: &mut Vec<StatusFromDb>,
252252- handle_resolver: &HandleResolver,
253253-) -> Result<()> {
254254- let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
255255-256256- for db_status in statuses.iter_mut() {
257257- let authors_did = Did::new(db_status.author_did.clone())
258258- .map_err(|e| AppError::InternalError(format!("Failed to parse DID: {}", e)))?;
259259-260260- // Check cache first
261261- if let Some(found_handle) = quick_resolve_map.get(&authors_did) {
262262- db_status.handle = Some(found_handle.clone());
263263- continue;
264264- }
265265-266266- // Resolve handle
267267- db_status.handle = match handle_resolver.resolve(&authors_did).await {
268268- Ok(did_doc) => {
269269- did_doc.also_known_as
270270- .and_then(|aka| {
271271- if aka.is_empty() {
272272- None
273273- } else {
274274- aka.first().map(|full_handle| {
275275- let handle = full_handle.replace("at://", "");
276276- quick_resolve_map.insert(authors_did.clone(), handle.clone());
277277- handle
278278- })
279279- }
280280- })
281281- }
282282- Err(err) => {
283283- log::debug!("Could not resolve handle for DID {}: {}", authors_did.as_str(), err);
284284- None
285285- }
286286- };
287287- }
288288-289289- Ok(())
290290-}
291291-292250/// Login endpoint
293251#[post("/login")]
294252async fn login_post(
···344302 _oauth_client: web::Data<OAuthClientType>,
345303 db_pool: web::Data<Arc<Pool>>,
346304 handle_resolver: web::Data<HandleResolver>,
347347- config: web::Data<config::Config>,
348305) -> Result<impl Responder> {
349349- // Owner handle from config
350350- let owner_handle = &config.owner_handle;
306306+ // Default owner of the domain
307307+ const OWNER_HANDLE: &str = "zzstoatzz.io";
351308352309 // Check if user is logged in
353310 match session.get::<String>("did").unwrap_or(None) {
···406363 http_client: Arc::new(DefaultHttpClient::default()),
407364 });
408365409409- let owner_handle_str = owner_handle.to_string();
410410- let owner_handle_parsed =
411411- atrium_api::types::string::Handle::new(owner_handle_str.clone()).ok();
412412- let owner_did = if let Some(handle) = owner_handle_parsed {
366366+ let owner_handle =
367367+ atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok();
368368+ let owner_did = if let Some(handle) = owner_handle {
413369 atproto_handle_resolver.resolve(&handle).await.ok()
414370 } else {
415371 None
···445401446402 let html = StatusTemplate {
447403 title: "nate's status",
448448- handle: owner_handle_str,
404404+ handle: OWNER_HANDLE.to_string(),
449405 status_options: &STATUS_OPTIONS,
450406 current_status,
451407 history,
···552508async fn owner_status_json(
553509 db_pool: web::Data<Arc<Pool>>,
554510 _handle_resolver: web::Data<HandleResolver>,
555555- config: web::Data<config::Config>,
556511) -> Result<impl Responder> {
557557- // Owner handle from config
558558- let owner_handle = &config.owner_handle;
512512+ // Default owner of the domain
513513+ const OWNER_HANDLE: &str = "zzstoatzz.io";
559514560515 // Resolve handle to DID using ATProto handle resolution
561516 let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
···564519 });
565520566521 let did = match atproto_handle_resolver
567567- .resolve(&owner_handle.parse().expect("failed to parse handle"))
522522+ .resolve(&OWNER_HANDLE.parse().expect("failed to parse handle"))
568523 .await
569524 {
570525 Ok(d) => Some(d.to_string()),
571526 Err(e) => {
572572- log::error!("Failed to resolve handle {}: {}", owner_handle, e);
527527+ log::error!("Failed to resolve handle {}: {}", OWNER_HANDLE, e);
573528 None
574529 }
575530 };
···594549595550 let response = if let Some(status_data) = current_status {
596551 serde_json::json!({
597597- "handle": owner_handle,
552552+ "handle": OWNER_HANDLE,
598553 "status": "known",
599554 "emoji": status_data.status,
600555 "text": status_data.text,
···603558 })
604559 } else {
605560 serde_json::json!({
606606- "handle": owner_handle,
561561+ "handle": OWNER_HANDLE,
607562 "status": "unknown",
608563 "message": "No current status is known"
609564 })
···634589 vec![]
635590 });
636591637637- // Resolve handles for all statuses
638638- if let Err(e) = resolve_handles_for_statuses(&mut statuses, &handle_resolver).await {
639639- log::error!("Error resolving handles: {}", e);
592592+ // Resolve handles for each status
593593+ let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
594594+ for db_status in &mut statuses {
595595+ let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did");
596596+ match quick_resolve_map.get(&authors_did) {
597597+ None => {}
598598+ Some(found_handle) => {
599599+ db_status.handle = Some(found_handle.clone());
600600+ continue;
601601+ }
602602+ }
603603+ db_status.handle = match handle_resolver.resolve(&authors_did).await {
604604+ Ok(did_doc) => match did_doc.also_known_as {
605605+ None => None,
606606+ Some(also_known_as) => match also_known_as.is_empty() {
607607+ true => None,
608608+ false => {
609609+ let full_handle = also_known_as.first().unwrap();
610610+ let handle = full_handle.replace("at://", "");
611611+ quick_resolve_map.insert(authors_did, handle.clone());
612612+ Some(handle)
613613+ }
614614+ },
615615+ },
616616+ Err(_) => None,
617617+ };
640618 }
641619642620 Ok(HttpResponse::Ok().json(statuses))
···747725748726/// JSON API endpoint for status - returns current status or "unknown"
749727#[get("/api/status")]
750750-async fn status_json(
751751- db_pool: web::Data<Arc<Pool>>,
752752- config: web::Data<config::Config>,
753753-) -> Result<impl Responder> {
754754- // For backwards compatibility, this returns the owner's status
755755- // Use owner's DID from config (admin DID)
756756- let owner_did = Did::new(config.admin_did.clone()).ok();
728728+async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> {
729729+ const OWNER_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io
730730+731731+ let owner_did = Did::new(OWNER_DID.to_string()).ok();
757732 let current_status = if let Some(ref did) = owner_did {
758733 StatusFromDb::my_status(&db_pool, did)
759734 .await
···796771 oauth_client: web::Data<OAuthClientType>,
797772 db_pool: web::Data<Arc<Pool>>,
798773 handle_resolver: web::Data<HandleResolver>,
799799- config: web::Data<config::Config>,
800774) -> Result<impl Responder> {
801775 // This is essentially the old home function
802776 const TITLE: &str = "status feed";
···807781 vec![]
808782 });
809783810810- // Resolve handles for all statuses
811811- if let Err(e) = resolve_handles_for_statuses(&mut statuses, &handle_resolver).await {
812812- log::error!("Error resolving handles: {}", e);
784784+ let mut quick_resolve_map: HashMap<Did, String> = HashMap::new();
785785+ for db_status in &mut statuses {
786786+ let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did");
787787+ match quick_resolve_map.get(&authors_did) {
788788+ None => {}
789789+ Some(found_handle) => {
790790+ db_status.handle = Some(found_handle.clone());
791791+ continue;
792792+ }
793793+ }
794794+ db_status.handle = match handle_resolver.resolve(&authors_did).await {
795795+ Ok(did_doc) => match did_doc.also_known_as {
796796+ None => None,
797797+ Some(also_known_as) => match also_known_as.is_empty() {
798798+ true => None,
799799+ false => {
800800+ let full_handle = also_known_as.first().unwrap();
801801+ let handle = full_handle.replace("at://", "");
802802+ quick_resolve_map.insert(authors_did, handle.clone());
803803+ Some(handle)
804804+ }
805805+ },
806806+ },
807807+ Err(err) => {
808808+ log::error!("Error resolving did: {err}");
809809+ None
810810+ }
811811+ };
813812 }
814813815814 match session.get::<String>("did").unwrap_or(None) {
···838837 )
839838 .await;
840839841841- let is_admin = is_admin(&did.to_string(), &config);
840840+ let is_admin = is_admin(&did.to_string());
842841 let html = FeedTemplate {
843842 title: TITLE,
844843 profile: match profile {
···11331132 session: Session,
11341133 db_pool: web::Data<Arc<Pool>>,
11351134 req: web::Json<HideStatusRequest>,
11361136- config: web::Data<config::Config>,
11371135) -> HttpResponse {
11381136 // Check if the user is logged in and is admin
11391137 match session.get::<String>("did").unwrap_or(None) {
11401138 Some(did_string) => {
11411141- if !is_admin(&did_string, &config) {
11391139+ if !is_admin(&did_string) {
11421140 return HttpResponse::Forbidden().json(serde_json::json!({
11431141 "error": "Admin access required"
11441142 }));
···12401238 .repo
12411239 .create_record(
12421240 atrium_api::com::atproto::repo::create_record::InputData {
12431243- collection: "io.zzstoatzz.status.record".parse()
12441244- .map_err(|e| AppError::InternalError(format!("Invalid collection: {}", e)))?,
12411241+ collection: "io.zzstoatzz.status.record".parse().unwrap(),
12451242 repo: did.into(),
12461243 rkey: None,
12471244 record: status.into(),
···13071304#[actix_web::main]
13081305async fn main() -> std::io::Result<()> {
13091306 dotenv().ok();
13101310-13111311- // Load configuration
13121312- let config = config::Config::from_env().expect("Failed to load configuration");
13131313- let app_config = config.clone();
13141314-13151315- env_logger::init_from_env(env_logger::Env::new().default_filter_or(&config.log_level));
13161316- let host = config.server_host.clone();
13171317- let port = config.server_port;
13071307+ env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
13081308+ let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
13091309+ let port = std::env::var("PORT")
13101310+ .unwrap_or_else(|_| "8080".to_string())
13111311+ .parse::<u16>()
13121312+ .unwrap_or(8080);
1318131313191319- // Use database URL from config
13201320- let db_connection_string = if config.database_url.starts_with("sqlite://") {
13211321- config.database_url.strip_prefix("sqlite://").unwrap_or(&config.database_url).to_string()
13221322- } else {
13231323- config.database_url.clone()
13241324- };
13141314+ //Uses a default sqlite db path or use the one from env
13151315+ let db_connection_string =
13161316+ std::env::var("DB_PATH").unwrap_or_else(|_| String::from("./statusphere.sqlite3"));
1325131713261318 //Crates a db pool to share resources to the db
13271319 let pool = match PoolBuilder::new().path(db_connection_string).open().await {
···13521344 // Create a new OAuth client
13531345 let http_client = Arc::new(DefaultHttpClient::default());
1354134613551355- // Check if we're running in production (non-localhost) or locally
13561356- let is_production = !config.oauth_redirect_base.starts_with("http://localhost")
13571357- && !config.oauth_redirect_base.starts_with("http://127.0.0.1");
13471347+ // Check if we're running in production (with PUBLIC_URL) or locally
13481348+ let public_url = std::env::var("PUBLIC_URL").ok();
1358134913591359- let client: OAuthClientType = if is_production {
13501350+ let client: OAuthClientType = if let Some(public_url) = public_url {
13601351 // Production configuration with AtprotoClientMetadata
13611361- log::info!("Configuring OAuth for production with URL: {}", config.oauth_redirect_base);
13521352+ log::info!("Configuring OAuth for production with URL: {}", public_url);
1362135313631363- let oauth_config = OAuthClientConfig {
13541354+ let config = OAuthClientConfig {
13641355 client_metadata: AtprotoClientMetadata {
13651365- client_id: format!("{}/client-metadata.json", config.oauth_redirect_base),
13661366- client_uri: Some(config.oauth_redirect_base.clone()),
13671367- redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)],
13561356+ client_id: format!("{}/client-metadata.json", public_url),
13571357+ client_uri: Some(public_url.clone()),
13581358+ redirect_uris: vec![format!("{}/oauth/callback", public_url)],
13681359 token_endpoint_auth_method: AuthMethod::None,
13691360 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
13701361 scopes: vec![
···13901381 state_store: SqliteStateStore::new(pool.clone()),
13911382 session_store: SqliteSessionStore::new(pool.clone()),
13921383 };
13931393- Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client"))
13841384+ Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"))
13941385 } else {
13951386 // Local development configuration with AtprotoLocalhostClientMetadata
13961387 log::info!("Configuring OAuth for local development at {}:{}", host, port);
1397138813981398- let oauth_config = OAuthClientConfig {
13891389+ let config = OAuthClientConfig {
13991390 client_metadata: AtprotoLocalhostClientMetadata {
14001391 redirect_uris: Some(vec![format!(
14011392 //This must match the endpoint you use the callback function
14021402- "http://{}:{}/oauth/callback", host, port
13931393+ "http://{host}:{port}/oauth/callback"
14031394 )]),
14041395 scopes: Some(vec![
14051396 Scope::Known(KnownScope::Atproto),
···14221413 state_store: SqliteStateStore::new(pool.clone()),
14231414 session_store: SqliteSessionStore::new(pool.clone()),
14241415 };
14251425- Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client"))
14161416+ Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"))
14261417 };
14271427- // Only start the firehose ingester if enabled (from config)
14281428- if app_config.enable_firehose {
14181418+ // Only start the firehose ingester if enabled (default: disabled locally)
14191419+ let enable_firehose = std::env::var("ENABLE_FIREHOSE")
14201420+ .unwrap_or_else(|_| "false".to_string())
14211421+ .parse::<bool>()
14221422+ .unwrap_or(false);
14231423+14241424+ if enable_firehose {
14291425 let arc_pool = Arc::new(pool.clone());
14301426 log::info!("Starting Jetstream firehose ingester");
14311427 //Spawns the ingester that listens for other's Statusphere updates
···14471443 .app_data(web::Data::new(client.clone()))
14481444 .app_data(web::Data::new(arc_pool.clone()))
14491445 .app_data(web::Data::new(handle_resolver.clone()))
14501450- .app_data(web::Data::new(app_config.clone()))
14511446 .app_data(rate_limiter.clone())
14521447 .wrap(
14531448 SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64]))