feat: lots of timeline improvements; icon urls for users

This commit is contained in:
nullishamy 2025-05-02 00:46:04 +01:00
parent 41c0091e98
commit a924415a74
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
15 changed files with 379 additions and 43 deletions

View file

@ -55,9 +55,9 @@ async fn main() {
r#"
INSERT INTO user (
id, acct, url, remote, username,
actor_id, display_name, created_at
actor_id, display_name, created_at, icon_url
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"#,
"9b9d497b-2731-435f-a929-e609ca69dac9",
"amy",
@ -66,7 +66,8 @@ async fn main() {
"amy",
"https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9",
"amy",
ts
ts,
"https://ferri.amy.mov/assets/pfp.png"
)
.execute(&mut *conn)
.await

View file

@ -44,10 +44,10 @@ impl From<db::User> for api::Account {
note: "".to_string(),
url: val.url,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar: val.icon_url.clone(),
avatar_static: val.icon_url.clone(),
header: val.icon_url.clone(),
header_static: val.icon_url,
followers_count: 0,
following_count: 0,
@ -79,6 +79,42 @@ impl From<db::User> for ap::Person {
owner: format!("https://ferri.amy.mov/users/{}", val.id.0),
public_key: include_str!("../../../public.pem").to_string(),
}),
icon: None
}
}
}
impl From<db::Post> for api::Status {
fn from(value: db::Post) -> api::Status {
api::Status {
id: value.id,
created_at: value.created_at.to_rfc3339(),
in_reply_to_id: None,
in_reply_to_account_id: None,
sensitive: false,
spoiler_text: String::new(),
visibility: "Public".to_string(),
language: "en-GB".to_string(),
uri: value.uri.clone(),
url: value.uri.0.to_string(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
content: value.content,
reblog: None,
application: None,
account: value.user.into(),
media_attachments: vec![],
mentions: vec![],
tags: vec![],
emojis: vec![],
card: None,
poll: None
}
}
}

View file

@ -40,7 +40,8 @@ pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result<d
u.url,
u.acct,
u.remote,
u.created_at
u.created_at,
u.icon_url
FROM "user" u
INNER JOIN "actor" a ON u.actor_id = a.id
WHERE u.id = ?1
@ -101,5 +102,142 @@ pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result<d
created_at: user_created,
url: record.url,
posts: db::UserPosts { last_post_at },
icon_url: record.icon_url
})
}
pub async fn user_by_actor_uri(uri: ObjectUri, conn: &mut SqliteConnection) -> Result<db::User, DbError> {
info!("fetching user by actor_uri '{:?}' from the database", uri);
let record = sqlx::query!(
r#"
SELECT
u.id as "user_id",
u.username,
u.actor_id,
u.display_name,
a.inbox,
a.outbox,
u.url,
u.acct,
u.remote,
u.created_at,
u.icon_url
FROM "user" u
INNER JOIN "actor" a ON u.actor_id = a.id
WHERE u.actor_id = ?1
"#,
uri.0
)
.fetch_one(&mut *conn)
.await
.map_err(|e| DbError::FetchError(e.to_string()))?;
let follower_count = sqlx::query_scalar!(
r#"
SELECT COUNT(follower_id)
FROM "follow"
WHERE followed_id = ?1
"#,
record.actor_id
)
.fetch_one(&mut *conn)
.await
.map_err(|e| DbError::FetchError(e.to_string()))?;
let last_post_at = sqlx::query_scalar!(
r#"
SELECT datetime(p.created_at)
FROM post p
WHERE p.user_id = ?1
ORDER BY datetime(p.created_at) DESC
LIMIT 1
"#,
record.user_id
)
.fetch_optional(&mut *conn)
.await
.map_err(|e| DbError::FetchError(e.to_string()))?
.flatten()
.and_then(|ts| {
info!("parsing timestamp {}", ts);
parse_ts(ts)
});
let user_created = parse_ts(record.created_at).expect("no db corruption");
info!("user {:?} has {} followers", record.user_id, follower_count);
info!("user {:?} last posted {:?}", record.user_id, last_post_at);
Ok(db::User {
id: ObjectUuid(record.user_id),
actor: db::Actor {
id: ObjectUri(record.actor_id),
inbox: record.inbox,
outbox: record.outbox,
},
acct: record.acct,
remote: record.remote,
username: record.username,
display_name: record.display_name,
created_at: user_created,
url: record.url,
posts: db::UserPosts { last_post_at },
icon_url: record.icon_url
})
}
pub async fn posts_for_user_id(
id: ObjectUuid,
conn: &mut SqliteConnection
) -> Result<Vec<db::Post>, DbError> {
let mut out = vec![];
let posts = sqlx::query!(r#"
SELECT
p.id as "post_id", u.id as "user_id",
p.content, p.uri as "post_uri", u.username, u.display_name,
u.actor_id, p.created_at as "post_created",
p.boosted_post_id, a.inbox, a.outbox, u.created_at as "user_created",
u.acct, u.remote, u.url as "user_url", u.icon_url
FROM post p
INNER JOIN user u on p.user_id = u.id
INNER JOIN actor a ON u.actor_id = a.id
WHERE p.user_id = ?
"#, id.0)
.fetch_all(&mut *conn)
.await
.unwrap();
for record in posts {
let user_created = parse_ts(record.user_created)
.expect("no db corruption");
out.push(db::Post {
id: ObjectUuid(record.post_id),
uri: ObjectUri(record.post_uri),
user: db::User {
id: ObjectUuid(record.user_id),
actor: db::Actor {
id: ObjectUri(record.actor_id),
inbox: record.inbox,
outbox: record.outbox,
},
acct: record.acct,
remote: record.remote,
username: record.username,
display_name: record.display_name,
created_at: user_created,
url: record.user_url,
icon_url: record.icon_url,
posts: db::UserPosts {
last_post_at: None
}
},
content: record.content,
created_at: parse_ts(record.post_created).unwrap(),
boosted_post: None
})
}
Ok(out)
}

View file

@ -5,8 +5,9 @@ pub async fn new_user(user: db::User, conn: &mut SqliteConnection) -> Result<db:
let ts = user.created_at.to_rfc3339();
sqlx::query!(
r#"
INSERT INTO user (id, acct, url, created_at, remote, username, actor_id, display_name)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
INSERT INTO user (id, acct, url, created_at, remote,
username, actor_id, display_name, icon_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"#,
user.id.0,
user.acct,
@ -15,7 +16,8 @@ pub async fn new_user(user: db::User, conn: &mut SqliteConnection) -> Result<db:
user.remote,
user.username,
user.actor.id.0,
user.display_name
user.display_name,
user.icon_url
)
.execute(conn)
.await

View file

@ -86,9 +86,20 @@ pub mod db {
pub remote: bool,
pub url: String,
pub created_at: DateTime<Utc>,
pub icon_url: String,
pub posts: UserPosts,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Post {
pub id: ObjectUuid,
pub uri: ObjectUri,
pub user: User,
pub content: String,
pub created_at: DateTime<Utc>,
pub boosted_post: Option<ObjectUuid>
}
}
pub mod ap {
@ -210,6 +221,25 @@ pub mod ap {
pub outbox: String,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub enum IconType {
Image
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct PersonIcon {
#[serde(rename = "type")]
pub ty: IconType,
pub url: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub width: i64,
#[serde(default)]
pub height: i64
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Person {
@ -227,6 +257,8 @@ pub mod ap {
pub name: String,
pub public_key: Option<UserKey>,
pub icon: Option<PersonIcon>
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
@ -271,6 +303,37 @@ pub mod api {
pub links: Vec<WebfingerLink>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Status {
pub id: ObjectUuid,
pub created_at: String,
pub in_reply_to_id: Option<ObjectUri>,
pub in_reply_to_account_id: Option<ObjectUri>,
pub sensitive: bool,
pub spoiler_text: String,
pub visibility: String,
pub language: String,
pub uri: ObjectUri,
pub url: String,
pub replies_count: i64,
pub reblogs_count: i64,
pub favourites_count: i64,
pub favourited: bool,
pub reblogged: bool,
pub muted: bool,
pub bookmarked: bool,
pub content: String,
pub reblog: Option<Box<Status>>,
pub application: Option<()>,
pub account: Account,
pub media_attachments: Vec<Option<()>>,
pub mentions: Vec<Option<()>>,
pub tags: Vec<Option<()>>,
pub emojis: Vec<Option<()>>,
pub card: Option<()>,
pub poll: Option<()>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Account {
pub id: ObjectUuid,

View file

@ -4,3 +4,4 @@ pub mod preferences;
pub mod status;
pub mod timeline;
pub mod user;
pub mod search;

View file

@ -0,0 +1,88 @@
use rocket::{
get, serde::json::Json, FromFormField, State,
};
use main::types::{api, get};
use rocket_db_pools::Connection;
use serde::{Deserialize, Serialize};
use tracing::{info, error};
use crate::{http_wrapper::HttpWrapper, AuthenticatedUser, Db};
#[derive(Serialize, Deserialize, FromFormField, Debug)]
#[serde(rename_all = "lowercase")]
pub enum SearchType {
Accounts,
Hashtags,
Statuses,
All
}
impl Default for SearchType {
fn default() -> Self {
Self::All
}
}
#[derive(Serialize, Deserialize)]
pub struct SearchResults {
statuses: Vec<api::Status>,
accounts: Vec<api::Account>,
hashtags: Vec<()>
}
#[get("/search?<q>&<type>")]
pub async fn search(
q: &str,
r#type: SearchType,
helpers: &State<crate::Helpers>,
mut db: Connection<Db>,
user: AuthenticatedUser
) -> Json<SearchResults> {
let ty = r#type;
info!("search for {} (ty: {:?})", q, ty);
let key_id = "https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9#main-key";
let http = HttpWrapper::new(&helpers.http, key_id);
let mut accounts = vec![];
let mut statuses = vec![];
match ty {
SearchType::Accounts => {
let person = {
let res = http.get_person(q).await;
if let Err(e) = res {
error!("could not load user {}: {}", q, e.to_string());
None
} else {
Some(res.unwrap())
}
};
let user = get::user_by_actor_uri(person.unwrap().obj.id, &mut **db)
.await
.unwrap();
accounts.push(user.into())
},
SearchType::Statuses => {
if q == "me" {
let st = get::posts_for_user_id(user.id, &mut **db)
.await
.unwrap();
for status in st.into_iter() {
statuses.push(status.into());
}
}
},
SearchType::Hashtags => todo!(),
SearchType::All => todo!(),
}
Json(SearchResults {
statuses,
accounts,
hashtags: vec![],
})
}

View file

@ -43,7 +43,7 @@ async fn create_status(
http: &HttpClient,
status: &Status,
) -> TimelineStatus {
let user = ap::User::from_id(&user.id, &mut **db).await.unwrap();
let user = ap::User::from_id(&user.id.0, &mut **db).await.unwrap();
let outbox = ap::Outbox::for_user(user.clone(), http);
let post_id = ap::new_id();

View file

@ -35,11 +35,8 @@ pub struct TimelineStatus {
#[get("/timelines/home")]
pub async fn home(
mut db: Connection<Db>,
helpers: &State<crate::Helpers>,
_user: AuthenticatedUser,
) -> Json<Vec<TimelineStatus>> {
let config = &helpers.config;
#[derive(sqlx::FromRow, Debug)]
struct Post {
is_boost_source: bool,
@ -51,6 +48,8 @@ pub async fn home(
boosted_post_id: Option<String>,
display_name: String,
username: String,
icon_url: String,
user_url: String
}
// FIXME: query! can't cope with this. returns a type error
@ -75,7 +74,7 @@ pub async fn home(
)
SELECT is_boost_source, p.id as "post_id", u.id as "user_id",
p.content, p.uri as "post_uri", u.username, u.display_name,
u.actor_id, p.created_at, p.boosted_post_id
u.actor_id, p.created_at, p.boosted_post_id, u.icon_url, u.url as "user_url"
FROM get_home_timeline_with_boosts
JOIN post p ON p.id = get_home_timeline_with_boosts.id
JOIN user u ON u.id = p.user_id;
@ -90,7 +89,6 @@ pub async fn home(
for record in posts.iter() {
let mut boost: Option<Box<TimelineStatus>> = None;
if let Some(ref boosted_id) = record.boosted_post_id {
let user_uri = config.user_url(&record.user_id);
let record = posts.iter().find(|p| &p.post_id == boosted_id).unwrap();
boost = Some(Box::new(TimelineStatus {
@ -123,11 +121,11 @@ pub async fn home(
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
url: record.user_url.clone(),
avatar: record.icon_url.clone(),
avatar_static: record.icon_url.clone(),
header: record.icon_url.clone(),
header_static: record.icon_url.clone(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
@ -137,7 +135,6 @@ pub async fn home(
}
if !record.is_boost_source {
let user_uri = config.user_web_url(&record.username);
out.push(TimelineStatus {
id: record.post_id.clone(),
created_at: record.created_at.clone(),
@ -168,11 +165,11 @@ pub async fn home(
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
url: record.user_url.clone(),
avatar: record.icon_url.clone(),
avatar_static: record.icon_url.clone(),
header: record.icon_url.clone(),
header_static: record.icon_url.clone(),
followers_count: 1,
following_count: 1,
statuses_count: 1,

View file

@ -66,7 +66,7 @@ pub async fn new_follow(
) -> Result<(), NotFound<String>> {
let http = &helpers.http;
let follower = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
let follower = ap::User::from_actor_id(&user.actor_id.0, &mut **db).await;
let followed = ap::User::from_id(uuid, &mut **db)
.await

View file

@ -57,6 +57,9 @@ async fn create_user(
};
let url = format!("https://ferri.amy.mov/{}", acct);
let icon_url = user.icon.as_ref().map(|ic| ic.url.clone()).unwrap_or(
"https://ferri.amy.mov/assets/pfp.png".to_string()
);
let uuid = Uuid::new_v4().to_string();
// FIXME: Pull from user
@ -65,10 +68,10 @@ async fn create_user(
r#"
INSERT INTO user (
id, acct, url, remote, username,
actor_id, display_name, created_at
actor_id, display_name, created_at, icon_url
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(actor_id) DO NOTHING;
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(actor_id) DO NOTHING;
"#,
uuid,
acct,
@ -77,7 +80,8 @@ async fn create_user(
user.preferred_username,
actor,
user.name,
ts
ts,
icon_url
)
.execute(conn)
.await
@ -170,6 +174,9 @@ async fn resolve_actor<'a>(
remote: remote_info.is_remote,
url: remote_info.web_url,
created_at: main::ap::now(),
icon_url: person.icon.map(|ic| ic.url).unwrap_or(
"https://ferri.amy.mov/assets/pfp.png".to_string()
),
posts: db::UserPosts { last_post_at: None },
};

View file

@ -6,7 +6,6 @@ use rocket::{
FromForm,
form::Form,
get, post,
response::Redirect,
response::status::BadRequest,
response::content::RawHtml,
serde::{Deserialize, Serialize, json::Json},
@ -81,8 +80,7 @@ pub async fn authorize(
client_id: &str,
scope: &str,
redirect_uri: &str,
response_type: &str,
mut db: Connection<Db>,
response_type: &str
) -> Result<RawHtml<String>, BadRequest<String>> {
if response_type != "code" {
error!("unknown response type {}", response_type);

View file

@ -2,7 +2,7 @@ use crate::http::HttpClient;
use main::types::ap;
use std::fmt::Debug;
use thiserror::Error;
use tracing::{Level, error, event};
use tracing::{Level, error, event, info};
pub struct HttpWrapper<'a> {
client: &'a HttpClient,
@ -54,6 +54,7 @@ impl<'a> HttpWrapper<'a> {
}
let raw_body = raw_body.unwrap();
info!("raw body {}", raw_body);
let decoded = serde_json::from_str::<T>(&raw_body);
if let Err(e) = decoded {

View file

@ -5,7 +5,7 @@ use endpoints::{
use tracing_subscriber::fmt;
use main::ap;
use main::{ap, types::{ObjectUri, ObjectUuid}};
use main::ap::http;
use main::config::Config;
@ -36,9 +36,9 @@ async fn activity_endpoint(_activity: String) {}
#[derive(Debug)]
pub struct AuthenticatedUser {
pub username: String,
pub id: String,
pub id: ObjectUuid,
pub token: String,
pub actor_id: String,
pub actor_id: ObjectUri,
}
#[derive(Debug)]
@ -72,9 +72,9 @@ impl<'a> FromRequest<'a> for AuthenticatedUser {
if let Ok(auth) = auth {
return Outcome::Success(AuthenticatedUser {
token: auth.token,
id: auth.id,
id: ObjectUuid(auth.id),
username: auth.display_name,
actor_id: auth.actor_id,
actor_id: ObjectUri(auth.actor_id),
});
}
}
@ -150,7 +150,10 @@ pub fn launch(cfg: Config) -> Rocket<Build> {
user_profile,
],
)
.mount("/api/v2", routes![api::instance::instance])
.mount("/api/v2", routes![
api::instance::instance,
api::search::search,
])
.mount(
"/api/v1",
routes![

View file

@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS user
username TEXT NOT NULL,
actor_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
icon_url TEXT NOT NULL,
FOREIGN KEY(actor_id) REFERENCES actor(id)
);