Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Status page routes (read)
.service(status_read::home)
.service(status_read::user_status_page)
.service(status_read::status_share_page)
.service(status_read::feed)
// Status JSON API routes (read)
.service(status_read::owner_status_json)
Expand Down
126 changes: 118 additions & 8 deletions src/api/status_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ use crate::resolver::HickoryDnsTxtResolver;
use crate::{
api::auth::OAuthClientType,
db::StatusFromDb,
templates::{ErrorTemplate, FeedTemplate, StatusTemplate},
templates::{ErrorTemplate, FeedTemplate, StatusShareTemplate, StatusTemplate},
};
use actix_session::Session;
use actix_web::{Responder, Result, get, web};
use actix_web::{HttpRequest, HttpResponse, Responder, Result, get, web};
use askama::Template;
use async_sqlite::Pool;
use atrium_api::types::string::Did;
Expand Down Expand Up @@ -40,7 +40,7 @@ pub async fn home(
.unwrap_or_else(|| did_string.clone()),
Err(_) => did_string.clone(),
};
let current_status = StatusFromDb::my_status(&db_pool, &did)
let mut current_status = StatusFromDb::my_status(&db_pool, &did)
.await
.unwrap_or(None)
.and_then(|s| {
Expand All @@ -51,12 +51,18 @@ pub async fn home(
}
Some(s)
});
let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10)
let mut history = StatusFromDb::load_user_statuses(&db_pool, &did, 10)
.await
.unwrap_or_else(|err| {
log::error!("Error loading status history: {err}");
vec![]
});
if let Some(ref mut status) = current_status {
status.handle = Some(handle.clone());
}
for status in &mut history {
status.handle = Some(handle.clone());
}
let is_admin_flag = is_admin(did.as_str());
let html = StatusTemplate {
title: "your status",
Expand All @@ -82,7 +88,7 @@ pub async fn home(
} else {
None
};
let current_status = if let Some(ref did) = owner_did {
let mut current_status = if let Some(ref did) = owner_did {
StatusFromDb::my_status(&db_pool, did)
.await
.unwrap_or(None)
Expand All @@ -97,7 +103,7 @@ pub async fn home(
} else {
None
};
let history = if let Some(ref did) = owner_did {
let mut history = if let Some(ref did) = owner_did {
StatusFromDb::load_user_statuses(&db_pool, did, 10)
.await
.unwrap_or_else(|err| {
Expand All @@ -107,6 +113,12 @@ pub async fn home(
} else {
vec![]
};
if let Some(ref mut status) = current_status {
status.handle = Some(OWNER_HANDLE.to_string());
}
for status in &mut history {
status.handle = Some(OWNER_HANDLE.to_string());
}
let html = StatusTemplate {
title: "nate's status",
handle: OWNER_HANDLE.to_string(),
Expand Down Expand Up @@ -163,7 +175,7 @@ pub async fn user_status_page(
Some(session_did) => session_did == did.to_string(),
None => false,
};
let current_status = StatusFromDb::my_status(&db_pool, &did)
let mut current_status = StatusFromDb::my_status(&db_pool, &did)
.await
.unwrap_or(None)
.and_then(|s| {
Expand All @@ -174,12 +186,18 @@ pub async fn user_status_page(
}
Some(s)
});
let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10)
let mut history = StatusFromDb::load_user_statuses(&db_pool, &did, 10)
.await
.unwrap_or_else(|err| {
log::error!("Error loading status history: {err}");
vec![]
});
if let Some(ref mut status) = current_status {
status.handle = Some(handle.clone());
}
for status in &mut history {
status.handle = Some(handle.clone());
}
let html = StatusTemplate {
title: &format!("@{} status", handle),
handle,
Expand All @@ -193,6 +211,98 @@ pub async fn user_status_page(
Ok(web::Html::new(html))
}

/// Public share page for a specific status
#[get("/s/{did}/{rkey}")]
pub async fn status_share_page(
req: HttpRequest,
params: web::Path<(String, String)>,
db_pool: web::Data<Arc<Pool>>,
handle_resolver: web::Data<HandleResolver>,
) -> Result<impl Responder> {
let (did, rkey) = params.into_inner();
let uri = format!("at://{}/io.zzstoatzz.status.record/{}", did, rkey);

let mut status = match StatusFromDb::load_by_uri(&db_pool, &uri).await {
Ok(Some(status)) => status,
Ok(None) => {
let html = ErrorTemplate {
title: "Status not found",
error: "We couldn't find that status any more.",
}
.render()
.expect("template should be valid");
return Ok(HttpResponse::NotFound()
.content_type("text/html; charset=utf-8")
.body(html));
}
Err(err) => {
log::error!("Database error loading status {}: {}", uri, err);
let html = ErrorTemplate {
title: "Something went wrong",
error: "We couldn't load that status right now.",
}
.render()
.expect("template should be valid");
return Ok(HttpResponse::InternalServerError()
.content_type("text/html; charset=utf-8")
.body(html));
}
};

let handle = match Did::new(status.author_did.clone()) {
Ok(did) => match handle_resolver.resolve(&did).await {
Ok(doc) => doc
.also_known_as
.and_then(|aka| aka.first().cloned())
.map(|h| h.replace("at://", "")),
Err(err) => {
log::debug!(
"Failed to resolve handle for {}: {}",
status.author_did,
err
);
None
}
},
Err(err) => {
log::warn!("Invalid DID on status {}: {}", status.uri, err);
None
}
};
status.handle = handle.clone();

let display_handle = status.author_display_name();
let meta_title = status.share_title();
let meta_description = status.share_description();
let share_text = status.share_text();
let profile_href = handle
.clone()
.map(|h| format!("/@{}", h))
.unwrap_or_else(|| format!("https://bsky.app/profile/{}", status.author_did));

let info = req.connection_info();
let canonical_url = format!("{}://{}/s/{}/{}", info.scheme(), info.host(), did, rkey);
let share_path = format!("/s/{}/{}", did, rkey);

let html = StatusShareTemplate {
title: "status",
status,
canonical_url,
display_handle,
meta_title,
meta_description,
share_text,
profile_href,
share_path,
}
.render()
.expect("template should be valid");

Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}

#[get("/json")]
pub async fn owner_status_json(
_session: Session,
Expand Down
71 changes: 71 additions & 0 deletions src/db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ impl StatusFromDb {
}
}

/// Loads a status by its ATProto URI
pub async fn load_by_uri(
pool: &Data<Arc<Pool>>,
uri: &str,
) -> Result<Option<Self>, async_sqlite::Error> {
let target_uri = uri.to_string();
pool.conn(move |conn| {
let mut stmt = conn.prepare("SELECT * FROM status WHERE uri = ?1 LIMIT 1")?;
stmt.query_row([target_uri.as_str()], Self::map_from_row)
.map(Some)
.or_else(|err| {
if err == async_sqlite::rusqlite::Error::QueryReturnedNoRows {
Ok(None)
} else {
Err(err)
}
})
})
.await
}

/// Helper to map from [Row] to [StatusDb]
fn map_from_row(row: &Row) -> Result<Self, async_sqlite::rusqlite::Error> {
Ok(Self {
Expand Down Expand Up @@ -251,6 +272,56 @@ impl StatusFromDb {
None => self.author_did.to_string(),
}
}

/// Friendly emoji label suitable for text-only contexts
pub fn share_emoji_label(&self) -> String {
if let Some(name) = self.status.strip_prefix("custom:") {
format!(":{}:", name)
} else {
self.status.clone()
}
}

fn share_caption(&self) -> Option<String> {
self.text
.as_ref()
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.map(|t| t.to_owned())
}

/// Short title combining emoji and handle for link previews
pub fn share_title(&self) -> String {
format!(
"{} @{}",
self.share_emoji_label(),
self.author_display_name()
)
}

/// Description prioritizing the freeform text when present
pub fn share_description(&self) -> String {
self.share_caption()
.unwrap_or_else(|| format!("{} shared a status", self.author_display_name()))
}

/// Combined share text used for copy/share flows
pub fn share_text(&self) -> String {
self.share_caption()
.map(|caption| format!("{} — {}", self.share_title(), caption))
.unwrap_or_else(|| self.share_title())
}

/// Returns the record key component of the ATProto URI (rkey)
pub fn record_key(&self) -> Option<String> {
self.uri.rsplit_once('/').map(|(_, rkey)| rkey.to_string())
}

/// Generates the relative share path used by the UI (e.g. `/s/did:plc:abc/rkey`)
pub fn share_path(&self) -> String {
let rkey = self.record_key().unwrap_or_default();
format!("/s/{}/{}", self.author_did, rkey)
}
}

/// AuthSession table data type
Expand Down
15 changes: 15 additions & 0 deletions src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ pub struct StatusTemplate<'a> {
pub is_admin: bool,
}

#[derive(Template)]
#[template(path = "status_share.html")]
pub struct StatusShareTemplate<'a> {
#[allow(dead_code)]
pub title: &'a str,
pub status: StatusFromDb,
pub canonical_url: String,
pub display_handle: String,
pub meta_title: String,
pub meta_description: String,
pub share_text: String,
pub profile_href: String,
pub share_path: String,
}

#[derive(Template)]
#[template(path = "feed.html")]
pub struct FeedTemplate<'a> {
Expand Down
Loading