Compare commits

..

No commits in common. "d59660da3738b78a2d990a0b2f2eac7f41aadacb" and "3719fae10253541dd63e9fbb7be79f1592f7d494" have entirely different histories.

29 changed files with 162 additions and 682 deletions

3
Cargo.lock generated
View file

@ -2162,10 +2162,7 @@ dependencies = [
"reqwest", "reqwest",
"rocket", "rocket",
"rocket_db_pools", "rocket_db_pools",
"serde",
"serde_json",
"sqlx", "sqlx",
"thiserror 2.0.12",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",

View file

@ -11,7 +11,6 @@ uuid = { version = "1.16.0", features = ["v4"] }
chrono = "0.4.40" chrono = "0.4.40"
rand = "0.8" rand = "0.8"
thiserror = "2.0.12" thiserror = "2.0.12"
serde_json = "1.0.140"
tracing = "0.1.40" tracing = "0.1.40"
tracing-appender = "0.2.3" tracing-appender = "0.2.3"

View file

@ -12,8 +12,8 @@ uuid = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
serde_json = { workspace = true }
base64 = "0.22.1" base64 = "0.22.1"
rsa = { version = "0.9.8", features = ["sha2"] } rsa = { version = "0.9.8", features = ["sha2"] }
url = "2.5.4" url = "2.5.4"
serde_json = "1.0.140"

View file

@ -12,9 +12,6 @@ pub use user::*;
mod post; mod post;
pub use post::*; pub use post::*;
mod request_queue;
pub use request_queue::*;
pub const AS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; pub const AS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
pub fn new_id() -> String { pub fn new_id() -> String {

View file

@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use sqlx::Sqlite; use sqlx::Sqlite;
const POST_TYPE: &str = "Note"; const POST_TYPE: &str = "Post";
#[derive(Clone)] #[derive(Clone)]
pub struct Post { pub struct Post {

View file

@ -1,59 +0,0 @@
use std::sync::mpsc;
use std::thread;
use tracing::{info, span, Level};
#[derive(Debug)]
pub enum QueueMessage {
Heartbeat
}
pub struct RequestQueue {
name: &'static str,
send: mpsc::Sender<QueueMessage>,
recv: mpsc::Receiver<QueueMessage>
}
#[derive(Clone)]
pub struct QueueHandle {
send: mpsc::Sender<QueueMessage>
}
impl QueueHandle {
pub fn send(&self, msg: QueueMessage) {
self.send.send(msg).unwrap();
}
}
impl RequestQueue {
pub fn new(name: &'static str) -> Self {
let (send, recv) = mpsc::channel();
Self {
name,
send,
recv
}
}
pub fn spawn(self) -> QueueHandle {
info!("starting up queue '{}'", self.name);
thread::spawn(move || {
info!("queue '{}' up", self.name);
let recv = self.recv;
while let Ok(req) = recv.recv() {
// FIXME: When we make this do async things we will need to add tokio and
// use proper async handled spans as the enter/drop won't work.
// See inbox.rs for how we handle that.
let s = span!(Level::INFO, "queue", queue_name = self.name);
let _enter = s.enter();
info!(?req, "got a message into the queue");
drop(_enter);
}
});
QueueHandle { send: self.send }
}
}

View file

@ -106,10 +106,9 @@ impl User {
"#, "#,
username username
) )
.fetch_one(conn) .fetch_one(conn)
.await .await
.unwrap(); .unwrap();
User { User {
id: user.id, id: user.id,
username: user.username, username: user.username,

View file

@ -9,41 +9,3 @@ pub struct ServerConfig {
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
} }
impl Config {
pub fn host(&self) -> &str {
&self.server.host
}
pub fn user_url(&self, user_uuid: &str) -> String {
format!("{}/users/{}", self.host(), user_uuid)
}
pub fn user_web_url(&self, user_name: &str) -> String {
format!("{}/{}", self.host(), user_name)
}
pub fn followers_url(&self, user_uuid: &str) -> String {
format!("{}/followers", self.user_url(user_uuid))
}
pub fn following_url(&self, user_uuid: &str) -> String {
format!("{}/following", self.user_url(user_uuid))
}
pub fn inbox_url(&self, user_uuid: &str) -> String {
format!("{}/inbox", self.user_url(user_uuid))
}
pub fn outbox_url(&self, user_uuid: &str) -> String {
format!("{}/outbox", self.user_url(user_uuid))
}
pub fn post_url(&self, poster_uuid: &str, post_uuid: &str) -> String {
format!("{}/{}", self.user_url(poster_uuid), post_uuid)
}
pub fn activity_url(&self, activity_uuid: &str) -> String {
format!("{}/activities/{}", self.host(), activity_uuid)
}
}

View file

@ -1,7 +1,5 @@
pub mod ap; pub mod ap;
pub mod config; pub mod config;
mod types_rewrite;
use rand::{Rng, distributions::Alphanumeric}; use rand::{Rng, distributions::Alphanumeric};
pub fn gen_token(len: usize) -> String { pub fn gen_token(len: usize) -> String {

View file

@ -1,28 +0,0 @@
use crate::types_rewrite::api;
use crate::types_rewrite::ap;
use crate::types_rewrite::db;
use crate::types_rewrite::{Object, as_context};
impl From<db::Actor> for ap::Actor {
fn from(val: db::Actor) -> ap::Actor {
ap::Actor {
obj: Object {
context: as_context(),
id: val.id
},
inbox: val.inbox,
outbox: val.outbox
}
}
}
impl From<ap::Actor> for db::Actor {
fn from(val: ap::Actor) -> db::Actor {
db::Actor {
id: val.obj.id,
inbox: val.inbox,
outbox: val.outbox
}
}
}

View file

@ -1,87 +0,0 @@
use serde::{Serialize, Deserialize};
mod convert;
pub use convert::*;
pub const AS_CONTEXT_RAW: &'static str = "https://www.w3.org/ns/activitystreams";
pub fn as_context() -> ObjectContext {
ObjectContext::Str(AS_CONTEXT_RAW.to_string())
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(untagged)]
pub enum ObjectContext {
Str(String),
Vec(Vec<serde_json::Value>),
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct ObjectUri(String);
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Object {
#[serde(rename = "@context")]
context: ObjectContext,
id: ObjectUri,
}
pub mod db {
use serde::{Serialize, Deserialize};
use super::*;
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Actor {
pub id: ObjectUri,
pub inbox: String,
pub outbox: String,
}
}
pub mod ap {
use serde::{Serialize, Deserialize};
use super::*;
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Actor {
#[serde(flatten)]
pub obj: Object,
pub inbox: String,
pub outbox: String,
}
}
pub mod api {
use serde::{Serialize, Deserialize};
use super::*;
// API will not really use actors so treat them as DB actors
// until we require specificity
pub type Actor = db::Actor;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ap_actor_to_db() {
let domain = "https://example.com";
let ap = ap::Actor {
obj: Object {
context: as_context(),
id: ObjectUri(format!("{}/users/sample", domain)),
},
inbox: format!("{}/users/sample/inbox", domain),
outbox: format!("{}/users/sample/outbox", domain),
};
let db: db::Actor = ap.into();
assert_eq!(db, db::Actor {
id: ObjectUri("https://example.com/users/sample".to_string()),
inbox: "https://example.com/users/sample/inbox".to_string(),
outbox: "https://example.com/users/sample/outbox".to_string(),
});
}
}

View file

@ -16,6 +16,3 @@ chrono = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }

View file

@ -1,6 +1,4 @@
use rocket::{get, serde::json::Json, State}; use rocket::{get, serde::json::Json};
use crate::Config;
use crate::types::instance::{ use crate::types::instance::{
Accounts, Configuration, Contact, Instance, MediaAttachments, Polls, Registrations, Statuses, Accounts, Configuration, Contact, Instance, MediaAttachments, Polls, Registrations, Statuses,
@ -8,9 +6,9 @@ use crate::types::instance::{
}; };
#[get("/instance")] #[get("/instance")]
pub async fn instance(config: &State<Config>) -> Json<Instance> { pub async fn instance() -> Json<Instance> {
Json(Instance { Json(Instance {
domain: config.host().to_string(), domain: "ferri.amy.mov".to_string(),
title: "Ferri".to_string(), title: "Ferri".to_string(),
version: "0.0.1".to_string(), version: "0.0.1".to_string(),
source_url: "https://forge.amy.mov/amy/ferri".to_string(), source_url: "https://forge.amy.mov/amy/ferri".to_string(),

View file

@ -26,11 +26,11 @@ pub struct StatusContext {
descendants: Vec<Status> descendants: Vec<Status>
} }
#[get("/statuses/<_status>/context")] #[get("/statuses/<status>/context")]
pub async fn status_context( pub async fn status_context(
_status: &str, status: &str,
_user: AuthenticatedUser, user: AuthenticatedUser,
_db: Connection<Db> mut db: Connection<Db>
) -> Json<StatusContext> { ) -> Json<StatusContext> {
Json(StatusContext { Json(StatusContext {
ancestors: vec![], ancestors: vec![],
@ -56,37 +56,44 @@ async fn create_status(
post.save(&mut **db).await; post.save(&mut **db).await;
let actor = sqlx::query!("SELECT * FROM actor WHERE id = ?1", "https://fedi.amy.mov/users/9zkygethkdw60001") let actors = sqlx::query!("SELECT * FROM actor")
.fetch_one(&mut **db) .fetch_all(&mut **db)
.await .await
.unwrap(); .unwrap();
let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()); for record in actors {
// Don't send to ourselves
if record.id == user.actor_id() {
continue;
}
let activity = ap::Activity { let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4());
id: create_id,
ty: ap::ActivityType::Create,
object: post.clone().to_ap(),
to: vec![format!("{}/followers", user.uri())],
published: now,
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
..Default::default()
};
let actor = ap::Actor::from_raw( let activity = ap::Activity {
actor.id.clone(), id: create_id,
actor.inbox.clone(), ty: ap::ActivityType::Create,
actor.outbox.clone(), object: post.clone().to_ap(),
); to: vec![format!("{}/followers", user.uri())],
published: now,
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
..Default::default()
};
let req = ap::OutgoingActivity { let actor = ap::Actor::from_raw(
req: activity, record.id.clone(),
signed_by: format!("{}#main-key", user.uri()), record.inbox.clone(),
to: actor, record.outbox.clone(),
}; );
req.save(&mut **db).await; let req = ap::OutgoingActivity {
outbox.post(req).await; req: activity,
signed_by: format!("{}#main-key", user.uri()),
to: actor,
};
req.save(&mut **db).await;
outbox.post(req).await;
}
TimelineStatus { TimelineStatus {
id: post.id().to_string(), id: post.id().to_string(),
@ -104,7 +111,6 @@ async fn create_status(
favourites_count: 0, favourites_count: 0,
favourited: false, favourited: false,
reblogged: false, reblogged: false,
reblog: None,
muted: false, muted: false,
bookmarked: false, bookmarked: false,
media_attachments: vec![], media_attachments: vec![],

View file

@ -1,6 +1,5 @@
use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount, Config}; use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount};
use rocket::{ use rocket::{
State,
get, get,
serde::{Deserialize, Serialize, json::Json}, serde::{Deserialize, Serialize, json::Json},
}; };
@ -28,21 +27,20 @@ pub struct TimelineStatus {
pub reblogged: bool, pub reblogged: bool,
pub muted: bool, pub muted: bool,
pub bookmarked: bool, pub bookmarked: bool,
pub reblog: Option<Box<TimelineStatus>>,
pub media_attachments: Vec<()>, pub media_attachments: Vec<()>,
pub account: TimelineAccount, pub account: TimelineAccount,
} }
#[get("/timelines/home")] #[get("/timelines/home?<limit>")]
pub async fn home( pub async fn home(
mut db: Connection<Db>, mut db: Connection<Db>,
config: &State<Config>, limit: i64,
_user: AuthenticatedUser, user: AuthenticatedUser,
) -> Json<Vec<TimelineStatus>> { ) -> Json<Vec<TimelineStatus>> {
let posts = sqlx::query!( let posts = sqlx::query!(
r#" r#"
SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", 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, p.boosted_post_id u.username, u.display_name, u.actor_id, p.created_at
FROM post p FROM post p
INNER JOIN user u on p.user_id = u.id INNER JOIN user u on p.user_id = u.id
"# "#
@ -53,65 +51,7 @@ pub async fn home(
let mut out = Vec::<TimelineStatus>::new(); let mut out = Vec::<TimelineStatus>::new();
for record in posts { for record in posts {
let mut boost: Option<Box<TimelineStatus>> = None; let user_uri = format!("https://ferri.amy.mov/users/{}", record.username);
if let Some(boosted_id) = record.boosted_post_id {
let record = 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, p.boosted_post_id
FROM post p
INNER JOIN user u on p.user_id = u.id
WHERE p.id = ?1
"#, boosted_id)
.fetch_one(&mut **db)
.await
.unwrap();
let user_uri = config.user_url(&record.user_id);
boost = Some(Box::new(TimelineStatus {
id: record.post_id.clone(),
created_at: record.created_at.clone(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: record.content.clone(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: record.post_uri.clone(),
url: record.post_uri.clone(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
reblog: boost,
muted: false,
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: record.user_id.clone(),
username: record.username.clone(),
acct: record.username.clone(),
display_name: record.display_name.clone(),
locked: false,
bot: false,
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(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
},
}))
}
let user_uri = config.user_web_url(&record.username);
out.push(TimelineStatus { out.push(TimelineStatus {
id: record.post_id.clone(), id: record.post_id.clone(),
created_at: record.created_at.clone(), created_at: record.created_at.clone(),
@ -128,7 +68,6 @@ pub async fn home(
favourites_count: 0, favourites_count: 0,
favourited: false, favourited: false,
reblogged: false, reblogged: false,
reblog: boost,
muted: false, muted: false,
bookmarked: false, bookmarked: false,
media_attachments: vec![], media_attachments: vec![],

View file

@ -95,7 +95,7 @@ pub async fn new_follow(
pub async fn account( pub async fn account(
mut db: Connection<Db>, mut db: Connection<Db>,
uuid: &str, uuid: &str,
_user: AuthenticatedUser, user: AuthenticatedUser,
) -> Result<Json<TimelineAccount>, NotFound<String>> { ) -> Result<Json<TimelineAccount>, NotFound<String>> {
let user = ap::User::from_id(uuid, &mut **db) let user = ap::User::from_id(uuid, &mut **db)
.await .await
@ -123,12 +123,12 @@ pub async fn account(
})) }))
} }
#[get("/accounts/<uuid>/statuses?<_limit>")] #[get("/accounts/<uuid>/statuses?<limit>")]
pub async fn statuses( pub async fn statuses(
mut db: Connection<Db>, mut db: Connection<Db>,
uuid: &str, uuid: &str,
_limit: Option<i64>, limit: Option<i64>,
_user: AuthenticatedUser, user: AuthenticatedUser,
) -> Result<Json<Vec<TimelineStatus>>, NotFound<String>> { ) -> Result<Json<Vec<TimelineStatus>>, NotFound<String>> {
let user = ap::User::from_id(uuid, &mut **db) let user = ap::User::from_id(uuid, &mut **db)
.await .await
@ -165,7 +165,6 @@ pub async fn statuses(
favourites_count: 0, favourites_count: 0,
favourited: false, favourited: false,
reblogged: false, reblogged: false,
reblog: None,
muted: false, muted: false,
bookmarked: false, bookmarked: false,
media_attachments: vec![], media_attachments: vec![],

View file

@ -1,8 +1,6 @@
use main::ap::http::HttpClient; use main::ap::http::HttpClient;
use rocket::{State, get, response::status}; use rocket::{State, get, response::status};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use main::ap;
use crate::OutboundQueue;
use uuid::Uuid; use uuid::Uuid;
@ -86,9 +84,7 @@ pub async fn resolve_user(acct: &str, host: &str) -> types::Person {
} }
#[get("/test")] #[get("/test")]
pub async fn test(http: &State<HttpClient>, outbound: &State<OutboundQueue>) -> &'static str { pub async fn test(http: &State<HttpClient>) -> &'static str {
outbound.0.send(ap::QueueMessage::Heartbeat);
let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await; let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await;
let post = activity::CreateActivity { let post = activity::CreateActivity {
@ -103,7 +99,6 @@ pub async fn test(http: &State<HttpClient>, outbound: &State<OutboundQueue>) ->
ts: "2025-04-10T10:48:11Z".to_string(), ts: "2025-04-10T10:48:11Z".to_string(),
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
attributed_to: None
}, },
ts: "2025-04-10T10:48:11Z".to_string(), ts: "2025-04-10T10:48:11Z".to_string(),
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()], to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
@ -123,7 +118,5 @@ pub async fn test(http: &State<HttpClient>, outbound: &State<OutboundQueue>) ->
.await .await
.unwrap(); .unwrap();
dbg!(follow);
"Hello, world!" "Hello, world!"
} }

View file

@ -1,5 +1,4 @@
use chrono::Local; use chrono::Local;
use tracing::Instrument;
use main::ap; use main::ap;
use rocket::serde::json::serde_json; use rocket::serde::json::serde_json;
use rocket::{State, post}; use rocket::{State, post};
@ -7,13 +6,12 @@ use rocket_db_pools::Connection;
use sqlx::Sqlite; use sqlx::Sqlite;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
use tracing::{event, span, Level, debug, warn, info, error}; use tracing::{event, span, Level, debug, warn, info};
use crate::http_wrapper::HttpWrapper;
use crate::{ use crate::{
Db, Db,
http::HttpClient, http::HttpClient,
types::{Person, content::Post, activity}, types::{Person, activity},
}; };
fn handle_delete_activity(activity: activity::DeleteActivity) { fn handle_delete_activity(activity: activity::DeleteActivity) {
@ -48,9 +46,7 @@ async fn create_user(
// HACK: Allow us to formulate a `user@host` username by assuming the actor is on the same host as the user // HACK: Allow us to formulate a `user@host` username by assuming the actor is on the same host as the user
let url = Url::parse(&actor).unwrap(); let url = Url::parse(&actor).unwrap();
let host = url.host_str().unwrap(); let host = url.host_str().unwrap();
info!("creating user '{}'@'{}' ({:#?})", user.preferred_username, host, user); let username = format!("{}@{}", user.name, host);
let username = format!("{}@{}", user.preferred_username, host);
let uuid = Uuid::new_v4().to_string(); let uuid = Uuid::new_v4().to_string();
sqlx::query!( sqlx::query!(
@ -62,7 +58,7 @@ async fn create_user(
uuid, uuid,
username, username,
actor, actor,
user.name user.preferred_username
) )
.execute(conn) .execute(conn)
.await .await
@ -88,27 +84,29 @@ async fn create_follow(
.unwrap(); .unwrap();
} }
async fn handle_follow_activity<'a>( async fn handle_follow_activity(
followed_account: &str, followed_account: &str,
activity: activity::FollowActivity, activity: activity::FollowActivity,
http: HttpWrapper<'a>, http: &HttpClient,
mut db: Connection<Db>, mut db: Connection<Db>,
) { ) {
let user = http.get_person(&activity.actor).await; let user = http
if let Err(e) = user { .get(&activity.actor)
error!("could not load user {}: {}", activity.actor, e.to_string()); .activity()
return .send()
} .await
.unwrap()
let user = user.unwrap(); .json::<Person>()
.await
.unwrap();
create_actor(&user, &activity.actor, &mut **db).await; create_actor(&user, &activity.actor, &mut **db).await;
create_user(&user, &activity.actor, &mut **db).await; create_user(&user, &activity.actor, &mut **db).await;
create_follow(&activity, &mut **db).await; create_follow(&activity, &mut **db).await;
let follower = ap::User::from_actor_id(&activity.actor, &mut **db).await; let follower = ap::User::from_actor_id(&activity.actor, &mut **db).await;
let followed = ap::User::from_id(&followed_account, &mut **db).await.unwrap(); let followed = ap::User::from_username(&followed_account, &mut **db).await;
let outbox = ap::Outbox::for_user(followed.clone(), http.client()); let outbox = ap::Outbox::for_user(followed.clone(), http);
let activity = ap::Activity { let activity = ap::Activity {
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()), id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
@ -144,109 +142,23 @@ async fn handle_like_activity(activity: activity::LikeActivity, mut db: Connecti
} }
} }
async fn handle_boost_activity<'a>( async fn handle_create_activity(
activity: activity::BoostActivity,
http: HttpWrapper<'a>,
mut db: Connection<Db>,
) {
let key_id = "https://ferri.amy.mov/users/amy#main-key";
dbg!(&activity);
let post = http
.client()
.get(&activity.object)
.activity()
.sign(&key_id)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
info!("{}", post);
let post = serde_json::from_str::<Post>(&post);
if let Err(e) = post {
error!(?e, "when decoding post");
return
}
let post = post.unwrap();
info!("{:#?}", post);
let attribution = post.attributed_to.unwrap();
let post_user = http.get_person(&attribution).await;
if let Err(e) = post_user {
error!("could not load post_user {}: {}", attribution, e.to_string());
return
}
let post_user = post_user.unwrap();
let user = http.get_person(&activity.actor).await;
if let Err(e) = user {
error!("could not load actor {}: {}", activity.actor, e.to_string());
return
}
let user = user.unwrap();
debug!("creating actor {}", activity.actor);
create_actor(&user, &activity.actor, &mut **db).await;
debug!("creating user {}", activity.actor);
create_user(&user, &activity.actor, &mut **db).await;
debug!("creating actor {}", attribution);
create_actor(&post_user, &attribution, &mut **db).await;
debug!("creating user {}", attribution);
create_user(&post_user, &attribution, &mut **db).await;
let attributed_user = ap::User::from_actor_id(&attribution, &mut **db).await;
let actor_user = ap::User::from_actor_id(&activity.actor, &mut **db).await;
let base_id = ap::new_id();
let now = ap::new_ts();
let reblog_id = ap::new_id();
let attr_id = attributed_user.id();
sqlx::query!("
INSERT INTO post (id, uri, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
", reblog_id, post.id, attr_id, post.content, post.ts)
.execute(&mut **db)
.await
.unwrap();
let uri = format!("https://ferri.amy.mov/users/{}/posts/{}", actor_user.id(), post.id);
let user_id = actor_user.id();
sqlx::query!("
INSERT INTO post (id, uri, user_id, content, created_at, boosted_post_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
", base_id, uri, user_id, "", now, reblog_id)
.execute(&mut **db)
.await
.unwrap();
}
async fn handle_create_activity<'a>(
activity: activity::CreateActivity, activity: activity::CreateActivity,
http: HttpWrapper<'a>, http: &HttpClient,
mut db: Connection<Db>, mut db: Connection<Db>,
) { ) {
assert!(&activity.object.ty == "Note"); assert!(&activity.object.ty == "Note");
debug!("resolving user {}", activity.actor); debug!("resolving user {}", activity.actor);
let user = http.get_person(&activity.actor).await; let user = http
if let Err(e) = user { .get(&activity.actor)
error!("could not load user {}: {}", activity.actor, e.to_string()); .activity()
return .send()
} .await
.unwrap()
let user = user.unwrap(); .json::<Person>()
.await
.unwrap();
debug!("creating actor {}", activity.actor); debug!("creating actor {}", activity.actor);
create_actor(&user, &activity.actor, &mut **db).await; create_actor(&user, &activity.actor, &mut **db).await;
@ -286,42 +198,31 @@ pub async fn inbox(db: Connection<Db>, http: &State<HttpClient>, user: &str, bod
let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap(); let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap();
let inbox_span = span!(Level::INFO, "inbox-post", user_id = user); let inbox_span = span!(Level::INFO, "inbox-post", user_id = user);
async move { let _enter = inbox_span.enter();
event!(Level::INFO, ?min, "received an activity"); event!(Level::INFO, ?min, "received an activity");
let key_id = "https://ferri.amy.mov/users/amy#main-key"; match min.ty.as_str() {
let wrapper = HttpWrapper::new(http.inner(), key_id); "Delete" => {
let activity = serde_json::from_str::<activity::DeleteActivity>(&body).unwrap();
match min.ty.as_str() { handle_delete_activity(activity);
"Delete" => { }
let activity = serde_json::from_str::<activity::DeleteActivity>(&body).unwrap(); "Follow" => {
handle_delete_activity(activity); let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap();
} handle_follow_activity(user, activity, http.inner(), db).await;
"Follow" => { }
let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap(); "Create" => {
handle_follow_activity(user, activity, wrapper, db).await; let activity = serde_json::from_str::<activity::CreateActivity>(&body).unwrap();
} handle_create_activity(activity, http.inner(), db).await;
"Create" => { }
let activity = serde_json::from_str::<activity::CreateActivity>(&body).unwrap(); "Like" => {
handle_create_activity(activity, wrapper, db).await; let activity = serde_json::from_str::<activity::LikeActivity>(&body).unwrap();
} handle_like_activity(activity, db).await;
"Like" => { }
let activity = serde_json::from_str::<activity::LikeActivity>(&body).unwrap(); act => {
handle_like_activity(activity, db).await; warn!(act, body, "unknown activity");
}
"Announce" => {
let activity = serde_json::from_str::<activity::BoostActivity>(&body).unwrap();
handle_boost_activity(activity, wrapper, db).await;
}
act => {
warn!(act, body, "unknown activity");
}
} }
debug!("body in inbox: {}", body);
} }
// Allow the span to be used inside the async code
// https://docs.rs/tracing/latest/tracing/span/struct.EnteredSpan.html#deref-methods-Span debug!("body in inbox: {}", body);
.instrument(inbox_span).await; drop(_enter)
} }

View file

@ -8,12 +8,12 @@ use rocket::{
}; };
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
#[get("/oauth/authorize?<client_id>&<scope>&<redirect_uri>&<_response_type>")] #[get("/oauth/authorize?<client_id>&<scope>&<redirect_uri>&<response_type>")]
pub async fn authorize( pub async fn authorize(
client_id: &str, client_id: &str,
scope: &str, scope: &str,
redirect_uri: &str, redirect_uri: &str,
_response_type: &str, response_type: &str,
mut db: Connection<Db>, mut db: Connection<Db>,
) -> Redirect { ) -> Redirect {
// For now, we will always authorize the request and assign it to an admin user // For now, we will always authorize the request and assign it to an admin user
@ -68,11 +68,11 @@ pub struct Token {
#[derive(Deserialize, Debug, FromForm)] #[derive(Deserialize, Debug, FromForm)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct NewTokenRequest { pub struct NewTokenRequest {
// pub client_id: String, client_id: String,
// pub redirect_uri: String, redirect_uri: String,
// pub grant_type: String, grant_type: String,
pub code: String, code: String,
// pub client_secret: String, client_secret: String,
} }
#[post("/oauth/token", data = "<req>")] #[post("/oauth/token", data = "<req>")]

View file

@ -1,19 +1,17 @@
use main::ap; use main::ap;
use rocket::{get, http::ContentType, serde::json::Json, State, Responder}; use rocket::{get, http::ContentType, serde::json::Json};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use rocket::response::Redirect;
use rocket::response::status::NotFound; use rocket::response::status::NotFound;
use crate::{ use crate::{
Config,
Db, Db,
types::{OrderedCollection, Person, UserKey, content}, types::{OrderedCollection, Person, UserKey, content},
}; };
use super::activity_type; use super::activity_type;
#[get("/users/<_user>/inbox")] #[get("/users/<user>/inbox")]
pub async fn inbox(_user: String) -> Json<OrderedCollection> { pub async fn inbox(user: String) -> Json<OrderedCollection> {
Json(OrderedCollection { Json(OrderedCollection {
ty: "OrderedCollection".to_string(), ty: "OrderedCollection".to_string(),
total_items: 0, total_items: 0,
@ -21,8 +19,8 @@ pub async fn inbox(_user: String) -> Json<OrderedCollection> {
}) })
} }
#[get("/users/<_user>/outbox")] #[get("/users/<user>/outbox")]
pub async fn outbox(_user: String) -> Json<OrderedCollection> { pub async fn outbox(user: String) -> Json<OrderedCollection> {
Json(OrderedCollection { Json(OrderedCollection {
ty: "OrderedCollection".to_string(), ty: "OrderedCollection".to_string(),
total_items: 0, total_items: 0,
@ -91,7 +89,6 @@ pub async fn following(mut db: Connection<Db>, uuid: &str) -> Result<Json<Ordere
#[get("/users/<uuid>/posts/<post>")] #[get("/users/<uuid>/posts/<post>")]
pub async fn post( pub async fn post(
mut db: Connection<Db>, mut db: Connection<Db>,
config: &State<Config>,
uuid: &str, uuid: &str,
post: String, post: String,
) -> (ContentType, Json<content::Post>) { ) -> (ContentType, Json<content::Post>) {
@ -109,67 +106,40 @@ pub async fn post(
activity_type(), activity_type(),
Json(content::Post { Json(content::Post {
context: "https://www.w3.org/ns/activitystreams".to_string(), context: "https://www.w3.org/ns/activitystreams".to_string(),
id: config.post_url(uuid, &post.id), id: format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id),
attributed_to: Some(config.user_url(uuid)),
ty: "Note".to_string(), ty: "Note".to_string(),
content: post.content, content: post.content,
ts: post.created_at, ts: post.created_at,
to: vec![config.followers_url(uuid)], to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()], cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
}), }),
) )
} }
#[derive(Debug, Responder)]
pub enum UserFetchError {
NotFound(NotFound<String>),
Moved(Redirect),
}
type ActivityResponse<T> = (ContentType, T);
fn ap_response<T>(t: T) -> ActivityResponse<T> {
(activity_type(), t)
}
fn ap_ok<T, E>(t: T) -> Result<ActivityResponse<T>, E> {
Ok(ap_response(t))
}
#[get("/users/<uuid>")] #[get("/users/<uuid>")]
pub async fn user( pub async fn user(mut db: Connection<Db>, uuid: &str) -> Result<(ContentType, Json<Person>), NotFound<String>> {
mut db: Connection<Db>,
config: &State<Config>,
uuid: &str
) -> Result<ActivityResponse<Json<Person>>, UserFetchError> {
if uuid == "amy" {
return Err(
UserFetchError::Moved(
Redirect::permanent("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9")
)
)
}
let user = ap::User::from_id(uuid, &mut **db) let user = ap::User::from_id(uuid, &mut **db)
.await .await
.map_err(|e| UserFetchError::NotFound(NotFound(e.to_string())))?; .map_err(|e| NotFound(e.to_string()))?;
let person = Person { Ok((
context: "https://www.w3.org/ns/activitystreams".to_string(), activity_type(),
ty: "Person".to_string(), Json(Person {
id: config.user_url(user.id()), context: "https://www.w3.org/ns/activitystreams".to_string(),
name: user.username().to_string(), ty: "Person".to_string(),
preferred_username: user.display_name().to_string(), id: format!("https://ferri.amy.mov/users/{}", user.id()),
followers: config.followers_url(user.id()), name: user.username().to_string(),
following: config.following_url(user.id()), preferred_username: user.display_name().to_string(),
summary: format!("ferri {}", user.username()), followers: format!("https://ferri.amy.mov/users/{}/followers", uuid),
inbox: config.inbox_url(user.id()), following: format!("https://ferri.amy.mov/users/{}/following", uuid),
outbox: config.outbox_url(user.id()), summary: format!("ferri {}", user.username()),
public_key: Some(UserKey { inbox: format!("https://ferri.amy.mov/users/{}/inbox", uuid),
id: format!("https://ferri.amy.mov/users/{}#main-key", uuid), outbox: format!("https://ferri.amy.mov/users/{}/outbox", uuid),
owner: config.user_url(user.id()), public_key: Some(UserKey {
public_key: include_str!("../../../public.pem").to_string(), id: format!("https://ferri.amy.mov/users/{}#main-key", uuid),
owner: format!("https://ferri.amy.mov/users/{}", uuid),
public_key: include_str!("../../../public.pem").to_string(),
}),
}), }),
}; ))
ap_ok(Json(person))
} }

View file

@ -1,10 +1,9 @@
use main::ap; use main::ap;
use rocket::{get, serde::json::Json, State}; use rocket::{get, serde::json::Json};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use tracing::info; use tracing::info;
use crate::{ use crate::{
Config,
Db, Db,
types::webfinger::{Link, WebfingerResponse}, types::webfinger::{Link, WebfingerResponse},
}; };
@ -21,7 +20,7 @@ pub async fn host_meta() -> &'static str {
// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social // https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social
#[get("/.well-known/webfinger?<resource>")] #[get("/.well-known/webfinger?<resource>")]
pub async fn webfinger(mut db: Connection<Db>, config: &State<Config>, resource: &str) -> Json<WebfingerResponse> { pub async fn webfinger(mut db: Connection<Db>, resource: &str) -> Json<WebfingerResponse> {
info!(?resource, "incoming webfinger request"); info!(?resource, "incoming webfinger request");
let acct = resource.strip_prefix("acct:").unwrap(); let acct = resource.strip_prefix("acct:").unwrap();
@ -31,19 +30,19 @@ pub async fn webfinger(mut db: Connection<Db>, config: &State<Config>, resource:
Json(WebfingerResponse { Json(WebfingerResponse {
subject: resource.to_string(), subject: resource.to_string(),
aliases: vec![ aliases: vec![
config.user_url(user.id()), format!("https://ferri.amy.mov/users/{}", user.id()),
config.user_web_url(user.username()) format!("https://ferri.amy.mov/{}", user.username()),
], ],
links: vec![ links: vec![
Link { Link {
rel: "http://webfinger.net/rel/profile-page".to_string(), rel: "http://webfinger.net/rel/profile-page".to_string(),
ty: Some("text/html".to_string()), ty: Some("text/html".to_string()),
href: Some(config.user_web_url(user.username())), href: Some(format!("https://ferri.amy.mov/{}", user.username())),
}, },
Link { Link {
rel: "self".to_string(), rel: "self".to_string(),
ty: Some("application/activity+json".to_string()), ty: Some("application/activity+json".to_string()),
href: Some(config.user_url(user.id())), href: Some(format!("https://ferri.amy.mov/users/{}", user.id())),
}, },
], ],
}) })

View file

@ -1,71 +0,0 @@
use thiserror::Error;
use tracing::{error, event, Level};
use crate::http::HttpClient;
use crate::types::Person;
use std::fmt::Debug;
pub struct HttpWrapper<'a> {
client: &'a HttpClient,
key_id: &'a str
}
#[derive(Error, Debug)]
pub enum HttpError {
#[error("entity of type `{0}` @ URL `{1}` could not be loaded")]
LoadFailure(String, String),
#[error("entity of type `{0}` @ URL `{1}` could not be parsed ({2})")]
ParseFailure(String, String, String),
}
impl <'a> HttpWrapper<'a> {
pub fn new(client: &'a HttpClient, key_id: &'a str) -> HttpWrapper<'a> {
Self {
client,
key_id
}
}
pub fn client(&self) -> &'a HttpClient {
&self.client
}
async fn get<T : serde::de::DeserializeOwned + Debug>(&self, ty: &str, url: &str) -> Result<T, HttpError> {
let ty = ty.to_string();
event!(Level::INFO, url, "loading {}", ty);
let http_result = self.client
.get(url)
.sign(self.key_id)
.activity()
.send()
.await;
if let Err(e) = http_result {
error!("could not load url {}: {:#?}", url, e);
return Err(HttpError::LoadFailure(ty, url.to_string()));
}
let raw_body = http_result.unwrap().text().await;
if let Err(e) = raw_body {
error!("could not get text for url {}: {:#?}", url, e);
return Err(HttpError::LoadFailure(ty, url.to_string()));
}
let decoded = serde_json::from_str::<T>(&raw_body.unwrap());
if let Err(e) = decoded {
error!("could not parse {} for url {}: {:#?}", ty, url, e);
return Err(HttpError::ParseFailure(
ty,
url.to_string(),
e.to_string()
));
}
Ok(decoded.unwrap())
}
pub async fn get_person(&self, url: &str) -> Result<Person, HttpError> {
self.get("Person", url).await
}
}

View file

@ -3,10 +3,9 @@ use endpoints::{
custom, inbox, oauth, user, well_known, custom, inbox, oauth, user, well_known,
}; };
use tracing::Level;
use tracing_subscriber::fmt; use tracing_subscriber::fmt;
use main::ap;
use main::ap::http; use main::ap::http;
use main::config::Config; use main::config::Config;
use rocket::{ use rocket::{
@ -20,24 +19,23 @@ use rocket_db_pools::{Connection, Database, sqlx};
mod cors; mod cors;
mod endpoints; mod endpoints;
mod types; mod types;
mod http_wrapper;
#[derive(Database)] #[derive(Database)]
#[database("sqlite_ferri")] #[database("sqlite_ferri")]
pub struct Db(sqlx::SqlitePool); pub struct Db(sqlx::SqlitePool);
#[get("/")] #[get("/")]
async fn user_profile() -> (ContentType, &'static str) { async fn user_profile(cfg: &rocket::State<Config>) -> (ContentType, &'static str) {
(ContentType::HTML, "<p>hello</p>") (ContentType::HTML, "<p>hello</p>")
} }
#[get("/activities/<_activity>")] #[get("/activities/<activity>")]
async fn activity_endpoint(_activity: String) { async fn activity_endpoint(activity: String) {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct AuthenticatedUser { struct AuthenticatedUser {
pub username: String, pub username: String,
pub id: String, pub id: String,
pub token: String, pub token: String,
@ -45,7 +43,10 @@ pub struct AuthenticatedUser {
} }
#[derive(Debug)] #[derive(Debug)]
pub enum LoginError { enum LoginError {
InvalidData,
UsernameDoesNotExist,
WrongPassword,
} }
#[rocket::async_trait] #[rocket::async_trait]
@ -87,8 +88,7 @@ impl<'a> FromRequest<'a> for AuthenticatedUser {
} }
} }
pub struct OutboundQueue(pub ap::QueueHandle);
pub struct InboundQueue(pub ap::QueueHandle);
pub fn launch(cfg: Config) -> Rocket<Build> { pub fn launch(cfg: Config) -> Rocket<Build> {
let format = fmt::format() let format = fmt::format()
@ -105,18 +105,10 @@ pub fn launch(cfg: Config) -> Rocket<Build> {
.with_writer(std::io::stdout) .with_writer(std::io::stdout)
.init(); .init();
let outbound = ap::RequestQueue::new("outbound");
let outbound_handle = outbound.spawn();
let inbound = ap::RequestQueue::new("inbound");
let inbound_handle = inbound.spawn();
let http_client = http::HttpClient::new(); let http_client = http::HttpClient::new();
build() build()
.manage(cfg) .manage(cfg)
.manage(http_client) .manage(http_client)
.manage(OutboundQueue(outbound_handle))
.manage(InboundQueue(inbound_handle))
.attach(Db::init()) .attach(Db::init())
.attach(cors::CORS) .attach(cors::CORS)
.mount("/assets", rocket::fs::FileServer::from("./assets")) .mount("/assets", rocket::fs::FileServer::from("./assets"))

View file

@ -1,4 +1,5 @@
use rocket::serde::{Deserialize, Serialize}; use rocket::serde::{Deserialize, Serialize};
use crate::types::content::Post; use crate::types::content::Post;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -59,17 +60,3 @@ pub struct AcceptActivity {
pub object: String, pub object: String,
pub actor: String, pub actor: String,
} }
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct BoostActivity {
#[serde(rename = "type")]
pub ty: String,
pub id: String,
pub actor: String,
pub published: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub object: String,
}

View file

@ -15,7 +15,4 @@ pub struct Post {
pub content: String, pub content: String,
pub to: Vec<String>, pub to: Vec<String>,
pub cc: Vec<String>, pub cc: Vec<String>,
#[serde(rename = "attributedTo")]
pub attributed_to: Option<String>
} }

View file

@ -34,7 +34,6 @@ pub struct Person {
pub inbox: String, pub inbox: String,
pub outbox: String, pub outbox: String,
pub preferred_username: String, pub preferred_username: String,
#[serde(default)]
pub name: String, pub name: String,
pub public_key: Option<UserKey>, pub public_key: Option<UserKey>,
} }

View file

@ -29,7 +29,6 @@
packages = with pkgs; [ packages = with pkgs; [
sqlx-cli sqlx-cli
cargo-nextest
(rust-bin.stable.latest.default.override { (rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" ]; extensions = [ "rust-src" "rust-analyzer" ];
targets = [ ]; targets = [ ];

View file

@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS follow
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
follower_id TEXT NOT NULL, follower_id TEXT NOT NULL,
followed_id TEXT NOT NULL, followed_id TEXT NOT NULL,
FOREIGN KEY(follower_id) REFERENCES actor(id), FOREIGN KEY(follower_id) REFERENCES actor(id),
FOREIGN KEY(followed_id) REFERENCES actor(id) FOREIGN KEY(followed_id) REFERENCES actor(id)
); );

View file

@ -5,8 +5,6 @@ CREATE TABLE IF NOT EXISTS post
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
boosted_post_id TEXT,
FOREIGN KEY(user_id) REFERENCES user(id), FOREIGN KEY(user_id) REFERENCES user(id)
FOREIGN KEY(boosted_post_id) REFERENCES post(id)
); );