mirror of
https://github.com/nullishamy/ferri.git
synced 2025-04-29 20:29:23 +00:00
feat: better APIs; WIP timeline support
This commit is contained in:
parent
022e6f9c6d
commit
ce3a9bfb26
19 changed files with 425 additions and 211 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2158,6 +2158,7 @@ dependencies = [
|
||||||
"rocket",
|
"rocket",
|
||||||
"rocket_db_pools",
|
"rocket_db_pools",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
BIN
assets/pfp.png
Normal file
BIN
assets/pfp.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 190 KiB |
|
@ -2,6 +2,7 @@ use chrono::{DateTime, Local};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Sqlite;
|
use sqlx::Sqlite;
|
||||||
|
|
||||||
|
use std::fmt::Debug;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -11,6 +12,12 @@ pub struct Actor {
|
||||||
outbox: String,
|
outbox: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Actor {
|
||||||
|
pub fn from_raw(id: String, inbox: String, outbox: String) -> Self {
|
||||||
|
Self { id, inbox, outbox }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
id: String,
|
id: String,
|
||||||
|
@ -100,35 +107,74 @@ impl User {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ActivityType {
|
pub enum ActivityType {
|
||||||
Follow,
|
Follow,
|
||||||
|
Accept,
|
||||||
|
Create,
|
||||||
|
Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityType {
|
impl ActivityType {
|
||||||
fn to_raw(self) -> String {
|
fn to_raw(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
ActivityType::Follow => "Follow".to_string(),
|
ActivityType::Follow => "Follow".to_string(),
|
||||||
|
ActivityType::Accept => "Accept".to_string(),
|
||||||
|
ActivityType::Create => "Create".to_string(),
|
||||||
|
ActivityType::Unknown => "FIXME".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Activity {
|
pub struct Activity<T : Serialize + Debug> {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub ty: ActivityType,
|
pub ty: ActivityType,
|
||||||
pub object: String,
|
pub object: T,
|
||||||
pub published: DateTime<Local>,
|
pub published: DateTime<Local>,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl <T : Serialize + Debug + Default> Default for Activity<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: Default::default(),
|
||||||
|
ty: ActivityType::Unknown,
|
||||||
|
object: Default::default(),
|
||||||
|
published: Local::now(),
|
||||||
|
to: Default::default(),
|
||||||
|
cc: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type KeyId = String;
|
pub type KeyId = String;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OutgoingActivity {
|
pub struct OutgoingActivity<T : Serialize + Debug> {
|
||||||
pub signed_by: KeyId,
|
pub signed_by: KeyId,
|
||||||
pub req: Activity,
|
pub req: Activity<T>,
|
||||||
pub to: Actor,
|
pub to: Actor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl <T : Serialize + Debug> OutgoingActivity<T> {
|
||||||
|
pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
||||||
|
let ty = self.req.ty.clone().to_raw();
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO activity (id, ty, actor_id)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
"#,
|
||||||
|
self.req.id,
|
||||||
|
ty,
|
||||||
|
self.to.id
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
struct RawActivity {
|
struct RawActivity<T : Serialize + Debug> {
|
||||||
#[serde(rename = "@context")]
|
#[serde(rename = "@context")]
|
||||||
#[serde(skip_deserializing)]
|
#[serde(skip_deserializing)]
|
||||||
context: String,
|
context: String,
|
||||||
|
@ -138,7 +184,7 @@ struct RawActivity {
|
||||||
ty: String,
|
ty: String,
|
||||||
|
|
||||||
actor: String,
|
actor: String,
|
||||||
object: String,
|
object: T,
|
||||||
published: String,
|
published: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,7 +199,7 @@ impl<'a> Outbox<'a> {
|
||||||
&self.user
|
&self.user
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post(&self, activity: OutgoingActivity) {
|
pub async fn post<T : Serialize + Debug>(&self, activity: OutgoingActivity<T>) {
|
||||||
dbg!(&activity);
|
dbg!(&activity);
|
||||||
let raw = RawActivity {
|
let raw = RawActivity {
|
||||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
|
|
@ -11,3 +11,5 @@ reqwest = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
url = "2.5.4"
|
|
@ -3,3 +3,4 @@ pub mod apps;
|
||||||
pub mod instance;
|
pub mod instance;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
|
pub mod timeline;
|
|
@ -1,14 +1,15 @@
|
||||||
|
use chrono::Local;
|
||||||
|
use main::ap::{self, http::HttpClient};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
FromForm,
|
FromForm, State,
|
||||||
form::Form,
|
form::Form,
|
||||||
post,
|
post,
|
||||||
serde::{Deserialize, Serialize},
|
serde::{Deserialize, Serialize},
|
||||||
};
|
};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use main::ap;
|
|
||||||
|
|
||||||
use crate::{AuthenticatedUser, Db};
|
use crate::{AuthenticatedUser, Db, types::content};
|
||||||
#[derive(Serialize, Deserialize, Debug, FromForm)]
|
#[derive(Serialize, Deserialize, Debug, FromForm)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct Status {
|
pub struct Status {
|
||||||
|
@ -16,25 +17,88 @@ pub struct Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/statuses", data = "<status>")]
|
#[post("/statuses", data = "<status>")]
|
||||||
pub async fn new_status(mut db: Connection<Db>, status: Form<Status>, user: AuthenticatedUser) {
|
pub async fn new_status(
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
http: &State<HttpClient>,
|
||||||
|
status: Form<Status>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
|
let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
|
||||||
|
let outbox = ap::Outbox::for_user(user.clone(), http);
|
||||||
|
|
||||||
let post_id = Uuid::new_v4();
|
let post_id = Uuid::new_v4();
|
||||||
let uri = format!("https://ferri.amy.mov/users/{}/posts/{}", user.username(), post_id);
|
|
||||||
|
let uri = format!(
|
||||||
|
"https://ferri.amy.mov/users/{}/posts/{}",
|
||||||
|
user.username(),
|
||||||
|
post_id
|
||||||
|
);
|
||||||
let id = user.id();
|
let id = user.id();
|
||||||
|
let now = Local::now().to_rfc3339();
|
||||||
|
|
||||||
let post = sqlx::query!(
|
let post = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO post (id, user_id, content)
|
INSERT INTO post (id, user_id, content, created_at)
|
||||||
VALUES (?1, ?2, ?3)
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
uri,
|
uri,
|
||||||
id,
|
id,
|
||||||
status.status
|
status.status,
|
||||||
|
now
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **db)
|
.fetch_one(&mut **db)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
dbg!(user, status, post);
|
let actors = sqlx::query!("SELECT * FROM actor")
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for record in actors {
|
||||||
|
// Don't send to ourselves
|
||||||
|
if &record.id == user.actor_id() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4());
|
||||||
|
|
||||||
|
let activity = ap::Activity {
|
||||||
|
id: create_id,
|
||||||
|
ty: ap::ActivityType::Create,
|
||||||
|
object: content::Post {
|
||||||
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
id: uri.clone(),
|
||||||
|
content: status.status.clone(),
|
||||||
|
ty: "Note".to_string(),
|
||||||
|
ts: Local::now().to_rfc3339(),
|
||||||
|
to: vec![format!(
|
||||||
|
"https://ferri.amy.mov/users/{}/followers",
|
||||||
|
user.username()
|
||||||
|
)],
|
||||||
|
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
|
||||||
|
},
|
||||||
|
to: vec![format!(
|
||||||
|
"https://ferri.amy.mov/users/{}/followers",
|
||||||
|
user.username()
|
||||||
|
)],
|
||||||
|
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let actor = ap::Actor::from_raw(
|
||||||
|
record.id.clone(),
|
||||||
|
record.inbox.clone(),
|
||||||
|
record.outbox.clone(),
|
||||||
|
);
|
||||||
|
let req = ap::OutgoingActivity {
|
||||||
|
req: activity,
|
||||||
|
signed_by: format!("https://ferri.amy.mov/users/{}#main-key", user.username()),
|
||||||
|
to: actor,
|
||||||
|
};
|
||||||
|
|
||||||
|
req.save(&mut **db).await;
|
||||||
|
outbox.post(req).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
91
ferri-server/src/endpoints/api/timeline.rs
Normal file
91
ferri-server/src/endpoints/api/timeline.rs
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
use crate::{Db, endpoints::api::user::CredentialAcount};
|
||||||
|
use rocket::{
|
||||||
|
get,
|
||||||
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
|
};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
|
pub type TimelineAccount = CredentialAcount;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct TimelineStatus {
|
||||||
|
id: String,
|
||||||
|
created_at: String,
|
||||||
|
in_reply_to_id: Option<String>,
|
||||||
|
in_reply_to_account_id: Option<String>,
|
||||||
|
content: String,
|
||||||
|
visibility: String,
|
||||||
|
spoiler_text: String,
|
||||||
|
sensitive: bool,
|
||||||
|
uri: String,
|
||||||
|
url: String,
|
||||||
|
replies_count: i64,
|
||||||
|
reblogs_count: i64,
|
||||||
|
favourites_count: i64,
|
||||||
|
favourited: bool,
|
||||||
|
reblogged: bool,
|
||||||
|
muted: bool,
|
||||||
|
bookmarked: bool,
|
||||||
|
media_attachments: Vec<()>,
|
||||||
|
account: TimelineAccount,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/timelines/home?<limit>")]
|
||||||
|
pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus>> {
|
||||||
|
let posts = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT p.id as "post_id", u.id as "user_id", p.content, u.username, u.display_name, u.actor_id FROM post p
|
||||||
|
INNER JOIN user u on p.user_id = u.id
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut out = Vec::<TimelineStatus>::new();
|
||||||
|
for record in posts {
|
||||||
|
out.push(TimelineStatus {
|
||||||
|
id: record.post_id.clone(),
|
||||||
|
created_at: "2025-04-10T22:12:09Z".to_string(),
|
||||||
|
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_id.clone(),
|
||||||
|
url: record.post_id.clone(),
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
favourited: false,
|
||||||
|
reblogged: false,
|
||||||
|
muted: false,
|
||||||
|
bookmarked: false,
|
||||||
|
media_attachments: vec![],
|
||||||
|
account: CredentialAcount {
|
||||||
|
id: record.actor_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: record.actor_id.clone(),
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(out)
|
||||||
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
use chrono::Local;
|
|
||||||
use main::ap;
|
use main::ap;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
State, get, post,
|
State, get, post,
|
||||||
|
@ -72,7 +71,7 @@ pub async fn new_follow(
|
||||||
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
|
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
|
||||||
ty: ap::ActivityType::Follow,
|
ty: ap::ActivityType::Follow,
|
||||||
object: followed.actor_id().to_string(),
|
object: followed.actor_id().to_string(),
|
||||||
published: Local::now(),
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let req = ap::OutgoingActivity {
|
let req = ap::OutgoingActivity {
|
||||||
|
@ -84,5 +83,6 @@ pub async fn new_follow(
|
||||||
to: followed.actor().clone(),
|
to: followed.actor().clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
req.save(&mut **db).await;
|
||||||
outbox.post(req).await;
|
outbox.post(req).await;
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,7 +87,6 @@ pub async fn test(http: &State<HttpClient>) -> &'static str {
|
||||||
let post = activity::CreateActivity {
|
let post = activity::CreateActivity {
|
||||||
id: "https://ferri.amy.mov/activities/amy/20".to_string(),
|
id: "https://ferri.amy.mov/activities/amy/20".to_string(),
|
||||||
ty: "Create".to_string(),
|
ty: "Create".to_string(),
|
||||||
summary: "Amy create a note".to_string(),
|
|
||||||
actor: "https://ferri.amy.mov/users/amy".to_string(),
|
actor: "https://ferri.amy.mov/users/amy".to_string(),
|
||||||
object: content::Post {
|
object: content::Post {
|
||||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
|
|
173
ferri-server/src/endpoints/inbox.rs
Normal file
173
ferri-server/src/endpoints/inbox.rs
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
use main::ap;
|
||||||
|
use rocket::{State, post};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
use rocket::serde::json::serde_json;
|
||||||
|
use sqlx::Sqlite;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use chrono::Local;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Db,
|
||||||
|
http::HttpClient,
|
||||||
|
types::{Person, activity},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn handle_delete_activity(activity: activity::DeleteActivity) {
|
||||||
|
dbg!(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_actor(user: &Person, actor: String, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO actor (id, inbox, outbox)
|
||||||
|
VALUES ( ?1, ?2, ?3 )
|
||||||
|
ON CONFLICT(id) DO NOTHING;
|
||||||
|
"#,
|
||||||
|
actor,
|
||||||
|
user.inbox,
|
||||||
|
user.outbox
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(user: &Person, actor: String, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
||||||
|
// 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 host = url.host_str().unwrap();
|
||||||
|
let username = format!("{}@{}", user.name, host);
|
||||||
|
|
||||||
|
let uuid = Uuid::new_v4().to_string();
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user (id, username, actor_id, display_name)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(actor_id) DO NOTHING;
|
||||||
|
"#,
|
||||||
|
uuid,
|
||||||
|
username,
|
||||||
|
actor,
|
||||||
|
user.preferred_username
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_follow(activity: &activity::FollowActivity, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO follow (id, follower_id, followed_id)
|
||||||
|
VALUES ( ?1, ?2, ?3 )
|
||||||
|
ON CONFLICT(id) DO NOTHING;
|
||||||
|
"#,
|
||||||
|
activity.id,
|
||||||
|
activity.actor,
|
||||||
|
activity.object
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_follow_activity(followed_account: String, activity: activity::FollowActivity, http: &HttpClient, mut db: Connection<Db>) {
|
||||||
|
let user = http
|
||||||
|
.get(&activity.actor)
|
||||||
|
.activity()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Person>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
create_actor(&user, activity.actor.clone(), &mut **db).await;
|
||||||
|
create_user(&user, activity.actor.clone(), &mut **db).await;
|
||||||
|
create_follow(&activity, &mut **db).await;
|
||||||
|
|
||||||
|
let follower = ap::User::from_actor_id(&activity.actor, &mut **db).await;
|
||||||
|
let followed = ap::User::from_username(&followed_account, &mut **db).await;
|
||||||
|
let outbox = ap::Outbox::for_user(followed.clone(), http);
|
||||||
|
|
||||||
|
let activity = ap::Activity {
|
||||||
|
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
|
||||||
|
ty: ap::ActivityType::Accept,
|
||||||
|
object: activity.id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = ap::OutgoingActivity {
|
||||||
|
signed_by: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
followed.username()
|
||||||
|
),
|
||||||
|
req: activity,
|
||||||
|
to: follower.actor().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
req.save(&mut **db).await;
|
||||||
|
outbox.post(req).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpClient, mut db: Connection<Db>) {
|
||||||
|
assert!(&activity.object.ty == "Note");
|
||||||
|
let user = http
|
||||||
|
.get(&activity.actor)
|
||||||
|
.activity()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Person>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
create_actor(&user, activity.actor.clone(), &mut **db).await;
|
||||||
|
create_user(&user, activity.actor.clone(), &mut **db).await;
|
||||||
|
|
||||||
|
let user = ap::User::from_actor_id(&activity.actor, &mut **db).await;
|
||||||
|
|
||||||
|
let post_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let uri = format!(
|
||||||
|
"https://ferri.amy.mov/users/{}/posts/{}",
|
||||||
|
user.username(),
|
||||||
|
post_id
|
||||||
|
);
|
||||||
|
let id = user.id();
|
||||||
|
let now = Local::now().to_rfc3339();
|
||||||
|
let content = activity.object.content.clone();
|
||||||
|
|
||||||
|
sqlx::query!(r#"
|
||||||
|
INSERT INTO post (id, user_id, content, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
"#, uri, id, content, now)
|
||||||
|
.execute(&mut **db)
|
||||||
|
.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/users/<user>/inbox", data = "<body>")]
|
||||||
|
pub async fn inbox(db: Connection<Db>, http: &State<HttpClient>, user: String, body: String) {
|
||||||
|
let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap();
|
||||||
|
match min.ty.as_str() {
|
||||||
|
"Delete" => {
|
||||||
|
let activity = serde_json::from_str::<activity::DeleteActivity>(&body).unwrap();
|
||||||
|
handle_delete_activity(activity);
|
||||||
|
}
|
||||||
|
"Follow" => {
|
||||||
|
let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap();
|
||||||
|
handle_follow_activity(user, activity, http.inner(), db).await;
|
||||||
|
},
|
||||||
|
"Create" => {
|
||||||
|
let activity = serde_json::from_str::<activity::CreateActivity>(&body).unwrap();
|
||||||
|
handle_create_activity(activity, http.inner(), db).await;
|
||||||
|
},
|
||||||
|
unknown => {
|
||||||
|
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbg!(min);
|
||||||
|
println!("Body in inbox: {}", body);
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ pub mod oauth;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod well_known;
|
pub mod well_known;
|
||||||
pub mod custom;
|
pub mod custom;
|
||||||
|
pub mod inbox;
|
||||||
|
|
||||||
fn activity_type() -> ContentType {
|
fn activity_type() -> ContentType {
|
||||||
ContentType(MediaType::new("application", "activity+json"))
|
ContentType(MediaType::new("application", "activity+json"))
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
use main::ap;
|
use main::ap;
|
||||||
use rocket::{State, get, http::ContentType, post, serde::json::Json};
|
use rocket::{get, http::ContentType, serde::json::Json};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Db,
|
Db,
|
||||||
http::HttpClient,
|
types::{OrderedCollection, Person, UserKey, content},
|
||||||
types::{OrderedCollection, Person, UserKey, activity, content},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::serde::json::serde_json;
|
|
||||||
|
|
||||||
use super::activity_type;
|
use super::activity_type;
|
||||||
|
|
||||||
|
@ -22,91 +20,6 @@ pub async fn inbox(user: String) -> Json<OrderedCollection> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user>/inbox", data = "<body>")]
|
|
||||||
pub async fn post_inbox(
|
|
||||||
mut db: Connection<Db>,
|
|
||||||
http: &State<HttpClient>,
|
|
||||||
user: String,
|
|
||||||
body: String,
|
|
||||||
) {
|
|
||||||
let min = serde_json::from_str::<activity::MinimalActivity>(&body).unwrap();
|
|
||||||
match min.ty.as_str() {
|
|
||||||
"Delete" => {
|
|
||||||
let activity = serde_json::from_str::<activity::DeleteActivity>(&body);
|
|
||||||
dbg!(activity.unwrap());
|
|
||||||
}
|
|
||||||
"Follow" => {
|
|
||||||
let activity = serde_json::from_str::<activity::FollowActivity>(&body).unwrap();
|
|
||||||
dbg!(&activity);
|
|
||||||
|
|
||||||
let user = http
|
|
||||||
.get(&activity.actor)
|
|
||||||
.activity()
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json::<Person>()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO actor (id, inbox, outbox)
|
|
||||||
VALUES ( ?1, ?2, ?3 )
|
|
||||||
ON CONFLICT(id) DO NOTHING;
|
|
||||||
"#,
|
|
||||||
activity.actor,
|
|
||||||
user.inbox,
|
|
||||||
user.outbox
|
|
||||||
)
|
|
||||||
.execute(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO follow (id, follower_id, followed_id)
|
|
||||||
VALUES ( ?1, ?2, ?3 )
|
|
||||||
ON CONFLICT(id) DO NOTHING;
|
|
||||||
"#,
|
|
||||||
activity.id,
|
|
||||||
activity.actor,
|
|
||||||
activity.object
|
|
||||||
)
|
|
||||||
.execute(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let accept = activity::AcceptActivity {
|
|
||||||
ty: "Accept".to_string(),
|
|
||||||
actor: "https://ferri.amy.mov/users/amy".to_string(),
|
|
||||||
object: activity.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_id = "https://ferri.amy.mov/users/amy#main-key";
|
|
||||||
let accept_res = http
|
|
||||||
.post(user.inbox)
|
|
||||||
.json(&accept)
|
|
||||||
.sign(key_id)
|
|
||||||
.activity()
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
dbg!(accept_res);
|
|
||||||
}
|
|
||||||
unknown => {
|
|
||||||
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbg!(min);
|
|
||||||
println!("Body in inbox: {}", body);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/users/<user>/outbox")]
|
#[get("/users/<user>/outbox")]
|
||||||
pub async fn outbox(user: String) -> Json<OrderedCollection> {
|
pub async fn outbox(user: String) -> Json<OrderedCollection> {
|
||||||
dbg!(&user);
|
dbg!(&user);
|
||||||
|
|
|
@ -1,20 +1,14 @@
|
||||||
use main::ap::{http};
|
use main::ap::http;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
build, get, http::ContentType, request::{FromRequest, Outcome}, routes, serde::{
|
build, get, http::ContentType, request::{FromRequest, Outcome}, routes, Build, Request, Rocket
|
||||||
json::Json, Deserialize, Serialize
|
|
||||||
}, Build, Request, Rocket
|
|
||||||
};
|
};
|
||||||
|
use endpoints::{api::{self, timeline}, oauth, well_known, custom, user, inbox};
|
||||||
|
use rocket_db_pools::{sqlx, Database};
|
||||||
|
|
||||||
mod cors;
|
mod cors;
|
||||||
mod types;
|
mod types;
|
||||||
mod endpoints;
|
mod endpoints;
|
||||||
|
|
||||||
use endpoints::{api::{self, user::CredentialAcount}, oauth, well_known, custom, user};
|
|
||||||
|
|
||||||
use rocket_db_pools::sqlx;
|
|
||||||
use rocket_db_pools::Database;
|
|
||||||
|
|
||||||
#[derive(Database)]
|
#[derive(Database)]
|
||||||
#[database("sqlite_ferri")]
|
#[database("sqlite_ferri")]
|
||||||
pub struct Db(sqlx::SqlitePool);
|
pub struct Db(sqlx::SqlitePool);
|
||||||
|
@ -54,88 +48,18 @@ impl<'a> FromRequest<'a> for AuthenticatedUser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type TimelineAccount = CredentialAcount;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(crate = "rocket::serde")]
|
|
||||||
struct TimelineStatus {
|
|
||||||
id: String,
|
|
||||||
created_at: String,
|
|
||||||
in_reply_to_id: Option<String>,
|
|
||||||
in_reply_to_account_id: Option<String>,
|
|
||||||
content: String,
|
|
||||||
visibility: String,
|
|
||||||
spoiler_text: String,
|
|
||||||
sensitive: bool,
|
|
||||||
uri: String,
|
|
||||||
url: String,
|
|
||||||
replies_count: i64,
|
|
||||||
reblogs_count: i64,
|
|
||||||
favourites_count: i64,
|
|
||||||
favourited: bool,
|
|
||||||
reblogged: bool,
|
|
||||||
muted: bool,
|
|
||||||
bookmarked: bool,
|
|
||||||
media_attachments: Vec<()>,
|
|
||||||
account: TimelineAccount,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/timelines/home?<limit>")]
|
|
||||||
async fn home_timeline(limit: i64) -> Json<Vec<TimelineStatus>> {
|
|
||||||
Json(vec![TimelineStatus {
|
|
||||||
id: "1".to_string(),
|
|
||||||
created_at: "2025-04-10T22:12:09Z".to_string(),
|
|
||||||
in_reply_to_id: None,
|
|
||||||
in_reply_to_account_id: None,
|
|
||||||
content: "My first post".to_string(),
|
|
||||||
visibility: "public".to_string(),
|
|
||||||
spoiler_text: "".to_string(),
|
|
||||||
sensitive: false,
|
|
||||||
uri: "https://ferri.amy.mov/users/amy/posts/1".to_string(),
|
|
||||||
url: "https://ferri.amy.mov/users/amy/posts/1".to_string(),
|
|
||||||
replies_count: 0,
|
|
||||||
reblogs_count: 0,
|
|
||||||
favourites_count: 0,
|
|
||||||
favourited: false,
|
|
||||||
reblogged: false,
|
|
||||||
muted: false,
|
|
||||||
bookmarked: false,
|
|
||||||
media_attachments: vec![],
|
|
||||||
account: CredentialAcount {
|
|
||||||
id: "https://ferri.amy.mov/users/amy".to_string(),
|
|
||||||
username: "amy".to_string(),
|
|
||||||
acct: "amy@ferri.amy.mov".to_string(),
|
|
||||||
display_name: "amy".to_string(),
|
|
||||||
locked: false,
|
|
||||||
bot: false,
|
|
||||||
created_at: "2025-04-10T22:12:09Z".to_string(),
|
|
||||||
attribution_domains: vec![],
|
|
||||||
note: "".to_string(),
|
|
||||||
url: "https://ferri.amy.mov/@amy".to_string(),
|
|
||||||
avatar: "https://i.sstatic.net/l60Hf.png".to_string(),
|
|
||||||
avatar_static: "https://i.sstatic.net/l60Hf.png".to_string(),
|
|
||||||
header: "https://i.sstatic.net/l60Hf.png".to_string(),
|
|
||||||
header_static: "https://i.sstatic.net/l60Hf.png".to_string(),
|
|
||||||
followers_count: 1,
|
|
||||||
following_count: 1,
|
|
||||||
statuses_count: 1,
|
|
||||||
last_status_at: "2025-04-10T22:14:34Z".to_string(),
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn launch() -> Rocket<Build> {
|
pub fn launch() -> Rocket<Build> {
|
||||||
let http_client = http::HttpClient::new();
|
let http_client = http::HttpClient::new();
|
||||||
build()
|
build()
|
||||||
.manage(http_client)
|
.manage(http_client)
|
||||||
.attach(Db::init())
|
.attach(Db::init())
|
||||||
.attach(cors::CORS)
|
.attach(cors::CORS)
|
||||||
|
.mount("/assets", rocket::fs::FileServer::from("./assets"))
|
||||||
.mount(
|
.mount(
|
||||||
"/",
|
"/",
|
||||||
routes![
|
routes![
|
||||||
custom::test,
|
custom::test,
|
||||||
user::inbox,
|
user::inbox,
|
||||||
user::post_inbox,
|
|
||||||
user::outbox,
|
user::outbox,
|
||||||
user::user,
|
user::user,
|
||||||
user::followers,
|
user::followers,
|
||||||
|
@ -147,6 +71,7 @@ pub fn launch() -> Rocket<Build> {
|
||||||
activity_endpoint,
|
activity_endpoint,
|
||||||
well_known::webfinger,
|
well_known::webfinger,
|
||||||
well_known::host_meta,
|
well_known::host_meta,
|
||||||
|
inbox::inbox,
|
||||||
user_profile,
|
user_profile,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -160,7 +85,7 @@ pub fn launch() -> Rocket<Build> {
|
||||||
api::preferences::preferences,
|
api::preferences::preferences,
|
||||||
api::user::verify_credentials,
|
api::user::verify_credentials,
|
||||||
custom::finger_account,
|
custom::finger_account,
|
||||||
home_timeline
|
timeline::home
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,19 +2,6 @@ use rocket::serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::types::content::Post;
|
use crate::types::content::Post;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(crate = "rocket::serde")]
|
|
||||||
pub struct Activity {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: String,
|
|
||||||
|
|
||||||
pub summary: String,
|
|
||||||
pub actor: String,
|
|
||||||
pub object: String,
|
|
||||||
pub published: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct MinimalActivity {
|
pub struct MinimalActivity {
|
||||||
|
@ -45,9 +32,9 @@ pub struct CreateActivity {
|
||||||
pub actor: String,
|
pub actor: String,
|
||||||
pub to: Vec<String>,
|
pub to: Vec<String>,
|
||||||
pub cc: Vec<String>,
|
pub cc: Vec<String>,
|
||||||
|
|
||||||
#[serde(rename = "published")]
|
#[serde(rename = "published")]
|
||||||
pub ts: String,
|
pub ts: String,
|
||||||
pub summary: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use rocket::serde::{Deserialize, Serialize};
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
// FIXME: This is because Masto sends an array but we don't care
|
// FIXME: This is because Masto sends an array but we don't care
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
CREATE TABLE IF NOT EXISTS follow
|
CREATE TABLE IF NOT EXISTS follow
|
||||||
(
|
(
|
||||||
-- Activity ID
|
-- Activity ID
|
||||||
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),
|
||||||
|
|
|
@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS user
|
||||||
-- UUID
|
-- UUID
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
actor_id TEXT NOT NULL,
|
actor_id TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY(actor_id) REFERENCES actor(id)
|
FOREIGN KEY(actor_id) REFERENCES actor(id)
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
CREATE TABLE IF NOT EXISTS post
|
CREATE TABLE IF NOT EXISTS post
|
||||||
(
|
(
|
||||||
-- Uri
|
-- Uri
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY(user_id) REFERENCES user(id)
|
FOREIGN KEY(user_id) REFERENCES user(id)
|
||||||
);
|
);
|
||||||
|
|
10
migrations/20250411125138_add_activity.sql
Normal file
10
migrations/20250411125138_add_activity.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS activity
|
||||||
|
(
|
||||||
|
-- UUID
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
ty TEXT NOT NULL,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY(actor_id) REFERENCES actor(id)
|
||||||
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue