mirror of
https://github.com/nullishamy/ferri.git
synced 2025-04-29 20:29:23 +00:00
feat: initial commit
This commit is contained in:
commit
a4a1ef0745
19 changed files with 4436 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
|||
use flake
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
.direnv/
|
||||
target
|
3469
Cargo.lock
generated
Normal file
3469
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
10
Cargo.toml
Normal file
10
Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [ "ferri-cli","ferri-main", "ferri-server"]
|
||||
|
||||
[workspace.dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
serde = "1.0.219"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite", "macros" ], default-features = false }
|
||||
uuid = { version = "1.16.0", features = ["v4"] }
|
26
Rocket.toml
Normal file
26
Rocket.toml
Normal file
|
@ -0,0 +1,26 @@
|
|||
[default]
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
workers = 16
|
||||
max_blocking = 512
|
||||
keep_alive = 5
|
||||
ident = "Rocket"
|
||||
ip_header = "X-Real-IP" # set to `false` to disable
|
||||
log_level = "normal"
|
||||
temp_dir = "/tmp"
|
||||
cli_colors = true
|
||||
|
||||
[default.limits]
|
||||
form = "64 kB"
|
||||
json = "1 MiB"
|
||||
msgpack = "2 MiB"
|
||||
"file/jpg" = "5 MiB"
|
||||
|
||||
[default.shutdown]
|
||||
ctrlc = true
|
||||
signals = ["term", "hup"]
|
||||
grace = 5
|
||||
mercy = 5
|
||||
|
||||
[default.databases.sqlite_ferri]
|
||||
url = "./ferri.db"
|
11
ferri-cli/Cargo.toml
Normal file
11
ferri-cli/Cargo.toml
Normal file
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
main = { path = "../ferri-main/" }
|
||||
server = { path = "../ferri-server" }
|
||||
rocket = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
41
ferri-cli/src/main.rs
Normal file
41
ferri-cli/src/main.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use server::launch;
|
||||
extern crate rocket;
|
||||
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::env;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[arg(short, long)]
|
||||
init: bool
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
if cli.init {
|
||||
// Seed DB
|
||||
let pool = SqlitePool::connect(&env::var("DATABASE_URL").unwrap()).await.unwrap();
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
|
||||
sqlx::query!(r#"
|
||||
INSERT INTO actor (id, inbox, outbox)
|
||||
VALUES (?1, ?2, ?3)
|
||||
"#, "https://ferri.amy.mov/users/amy", "https://ferri.amy.mov/users/amy/inbox", "https://ferri.amy.mov/users/amy/outbox")
|
||||
.execute(&mut *conn)
|
||||
.await.unwrap();
|
||||
|
||||
sqlx::query!(r#"
|
||||
INSERT INTO user (id, actor_id, display_name)
|
||||
VALUES (?1, ?2, ?3)
|
||||
"#, "amy", "https://ferri.amy.mov/users/amy", "amy")
|
||||
.execute(&mut *conn)
|
||||
.await.unwrap();
|
||||
} else {
|
||||
let _ = launch().launch().await;
|
||||
}
|
||||
}
|
||||
|
7
ferri-main/Cargo.toml
Normal file
7
ferri-main/Cargo.toml
Normal file
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "main"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
27
ferri-main/src/ap/mod.rs
Normal file
27
ferri-main/src/ap/mod.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
pub type ObjectId = String;
|
||||
|
||||
pub enum ObjectType {
|
||||
Person,
|
||||
}
|
||||
|
||||
pub struct Object {
|
||||
id: ObjectId,
|
||||
ty: ObjectType
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
obj: Object,
|
||||
|
||||
inbox: Inbox,
|
||||
outbox: Outbox,
|
||||
}
|
||||
|
||||
pub struct Inbox {}
|
||||
|
||||
pub struct Outbox {}
|
||||
|
||||
pub struct Message {}
|
||||
|
||||
pub struct Activity {
|
||||
|
||||
}
|
1
ferri-main/src/lib.rs
Normal file
1
ferri-main/src/lib.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod ap;
|
17
ferri-server/Cargo.toml
Normal file
17
ferri-server/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
main = { path = "../ferri-main/" }
|
||||
rocket = { workspace = true }
|
||||
rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] }
|
||||
reqwest = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
base64 = "0.22.1"
|
||||
rsa = { version = "0.9.8", features = ["sha2"] }
|
||||
rand = "0.8"
|
||||
url = "2.5.4"
|
||||
chrono = "0.4.40"
|
||||
uuid = { workspace = true }
|
163
ferri-server/src/ap.rs
Normal file
163
ferri-server/src/ap.rs
Normal file
|
@ -0,0 +1,163 @@
|
|||
use rocket::serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Link {
|
||||
pub rel: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: Option<String>,
|
||||
pub href: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct WebfingerResponse {
|
||||
pub subject: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub links: Vec<Link>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct UserKey {
|
||||
pub id: String,
|
||||
pub owner: String,
|
||||
#[serde(rename = "publicKeyPem")]
|
||||
pub public_key: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Person {
|
||||
// FIXME: This is because Masto sends an array but we don't care
|
||||
#[serde(rename = "@context")]
|
||||
#[serde(skip_deserializing)]
|
||||
pub context: String,
|
||||
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
pub following: String,
|
||||
pub followers: String,
|
||||
pub inbox: String,
|
||||
pub outbox: String,
|
||||
pub preferred_username: String,
|
||||
pub name: String,
|
||||
pub summary: String,
|
||||
pub public_key: Option<UserKey>
|
||||
// pub url: String,
|
||||
// pub manually_approves_followers: bool,
|
||||
// pub discoverable: bool,
|
||||
// pub indexable: bool,
|
||||
// pub published: String,
|
||||
// pub memorial: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct MinimalActivity {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct DeleteActivity {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
|
||||
pub object: String,
|
||||
pub actor: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct CreateActivity {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
|
||||
pub object: Post,
|
||||
pub actor: String,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
#[serde(rename = "published")]
|
||||
pub ts: String,
|
||||
pub summary: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct FollowActivity {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
|
||||
pub object: String,
|
||||
pub actor: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct AcceptActivity {
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
|
||||
pub object: String,
|
||||
pub actor: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Post {
|
||||
// FIXME: This is because Masto sends an array but we don't care
|
||||
#[serde(rename = "@context")]
|
||||
#[serde(skip_deserializing)]
|
||||
pub context: String,
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
#[serde(rename = "published")]
|
||||
pub ts: String,
|
||||
pub content: String,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct Object {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
pub object: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct OrderedCollection {
|
||||
pub summary: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
pub total_items: u64,
|
||||
pub ordered_items: Vec<String>
|
||||
}
|
491
ferri-server/src/lib.rs
Normal file
491
ferri-server/src/lib.rs
Normal file
|
@ -0,0 +1,491 @@
|
|||
use rocket::serde::json::Json;
|
||||
use rocket::serde::json::serde_json;
|
||||
use rocket::{Rocket, Build, build, get, post, routes, http::{MediaType, ContentType}};
|
||||
use reqwest;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use base64::prelude::*;
|
||||
|
||||
use rocket::serde::Serialize;
|
||||
use rocket::serde::Deserialize;
|
||||
|
||||
use rocket::Request;
|
||||
use rocket::request::Outcome;
|
||||
use rocket::request::FromRequest;
|
||||
use rocket::FromForm;
|
||||
|
||||
use rsa::{RsaPrivateKey, pkcs8::DecodePrivateKey};
|
||||
use rsa::pkcs1v15::SigningKey;
|
||||
use rsa::signature::{RandomizedSigner, SignatureEncoding};
|
||||
use rsa::sha2::{Digest, Sha256};
|
||||
|
||||
use rocket::form::Form;
|
||||
|
||||
use url::Url;
|
||||
use chrono::Utc;
|
||||
|
||||
mod ap;
|
||||
|
||||
use rocket_db_pools::{Database, Connection};
|
||||
use rocket_db_pools::sqlx::{self, Row};
|
||||
|
||||
#[derive(Database)]
|
||||
#[database("sqlite_ferri")]
|
||||
struct Db(sqlx::SqlitePool);
|
||||
|
||||
#[get("/users/<user>/inbox")]
|
||||
async fn inbox(user: String) -> Json<ap::OrderedCollection> {
|
||||
dbg!(&user);
|
||||
Json(ap::OrderedCollection {
|
||||
ty: "OrderedCollection".to_string(),
|
||||
summary: format!("Inbox for {}", user),
|
||||
total_items: 0,
|
||||
ordered_items: vec![]
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/users/<user>/inbox", data="<body>")]
|
||||
async fn post_inbox(mut db: Connection<Db>, user: String, body: String) {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let min = serde_json::from_str::<ap::MinimalActivity>(&body).unwrap();
|
||||
match min.ty.as_str() {
|
||||
"Delete" => {
|
||||
let activity = serde_json::from_str::<ap::DeleteActivity>(&body);
|
||||
dbg!(activity);
|
||||
}
|
||||
"Follow" => {
|
||||
let activity = serde_json::from_str::<ap::FollowActivity>(&body).unwrap();
|
||||
dbg!(&activity);
|
||||
let user = client.get(&activity.actor)
|
||||
.header("Accept", "application/activity+json")
|
||||
.send()
|
||||
.await.unwrap()
|
||||
.json::<ap::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 = ap::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".to_string();
|
||||
let document = serde_json::to_string(&accept).unwrap();
|
||||
let signature = sign_post_request(key_id, user.inbox.clone(), document);
|
||||
dbg!(&signature);
|
||||
|
||||
let follow_res = client.post(user.inbox)
|
||||
.header("Content-Type", "application/activity+json")
|
||||
.header("Date", signature.date)
|
||||
.header("Digest", signature.digest)
|
||||
.header("Signature", signature.signature)
|
||||
.json(&accept)
|
||||
.send()
|
||||
.await.unwrap()
|
||||
.text()
|
||||
.await.unwrap();
|
||||
|
||||
dbg!(follow_res);
|
||||
}
|
||||
unknown => {
|
||||
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
|
||||
}
|
||||
}
|
||||
|
||||
dbg!(min);
|
||||
println!("body: {}", body);
|
||||
}
|
||||
|
||||
#[get("/users/<user>/outbox")]
|
||||
async fn outbox(user: String) -> Json<ap::OrderedCollection> {
|
||||
dbg!(&user);
|
||||
Json(ap::OrderedCollection {
|
||||
ty: "OrderedCollection".to_string(),
|
||||
summary: format!("Outbox for {}", user),
|
||||
total_items: 0,
|
||||
ordered_items: vec![]
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/users/<user>/followers")]
|
||||
async fn followers(mut db: Connection<Db>, user: String) -> Json<ap::OrderedCollection> {
|
||||
let target = FerriUser::by_name(&user, &mut **db).await;
|
||||
|
||||
let followers = sqlx::query!( r#"
|
||||
SELECT follower_id FROM follow
|
||||
WHERE followed_id = ?
|
||||
"#, target.actor_id)
|
||||
.fetch_all(&mut **db)
|
||||
.await.unwrap();
|
||||
|
||||
Json(ap::OrderedCollection {
|
||||
ty: "OrderedCollection".to_string(),
|
||||
summary: format!("Followers for {}", user),
|
||||
total_items: 1,
|
||||
ordered_items: followers.into_iter().map(|f| f.follower_id).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FerriUser {
|
||||
id: String,
|
||||
actor_id: String,
|
||||
display_name: String
|
||||
}
|
||||
|
||||
impl FerriUser {
|
||||
async fn by_name<'a>(
|
||||
name: &'a str,
|
||||
conn: impl sqlx::Executor<'a, Database = sqlx::Sqlite>
|
||||
) -> FerriUser {
|
||||
sqlx::query_as!(FerriUser, r#"
|
||||
SELECT * FROM user
|
||||
WHERE display_name = ?
|
||||
"#, name)
|
||||
.fetch_one(conn)
|
||||
.await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[get("/users/<user>/following")]
|
||||
async fn following(mut db: Connection<Db>, user: String) -> Json<ap::OrderedCollection> {
|
||||
let target = FerriUser::by_name(&user, &mut **db).await;
|
||||
|
||||
let following = sqlx::query!( r#"
|
||||
SELECT followed_id FROM follow
|
||||
WHERE follower_id = ?
|
||||
"#, target.actor_id)
|
||||
.fetch_all(&mut **db)
|
||||
.await.unwrap();
|
||||
|
||||
Json(ap::OrderedCollection {
|
||||
ty: "OrderedCollection".to_string(),
|
||||
summary: format!("Following for {}", user),
|
||||
total_items: 1,
|
||||
ordered_items: following.into_iter().map(|f| f.followed_id).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
fn activity_type() -> ContentType {
|
||||
ContentType(MediaType::new("application", "activity+json"))
|
||||
}
|
||||
|
||||
#[get("/users/<user>/posts/<post>")]
|
||||
async fn get_post(user: String, post: String) -> (ContentType, Json<ap::Post>) {
|
||||
(activity_type(), Json(ap::Post {
|
||||
id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post),
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
ty: "Note".to_string(),
|
||||
content: "My first post".to_string(),
|
||||
ts: "2025-04-10T10:48:11Z".to_string(),
|
||||
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
|
||||
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/users/<user>")]
|
||||
async fn user(user: String) -> (ContentType, Json<ap::Person>) {
|
||||
(activity_type(), Json(ap::Person {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
ty: "Person".to_string(),
|
||||
id: format!("https://ferri.amy.mov/users/{}", user),
|
||||
name: user.clone(),
|
||||
preferred_username: user.clone(),
|
||||
followers: format!("https://ferri.amy.mov/users/{}/followers", user),
|
||||
following: format!("https://ferri.amy.mov/users/{}/following", user),
|
||||
summary: format!("ferri {}", user),
|
||||
inbox: format!("https://ferri.amy.mov/users/{}/inbox", user),
|
||||
outbox: format!("https://ferri.amy.mov/users/{}/outbox", user),
|
||||
public_key: Some(ap::UserKey {
|
||||
id: format!("https://ferri.amy.mov/users/{}#main-key", user),
|
||||
owner: format!("https://ferri.amy.mov/users/{}", user),
|
||||
public_key: include_str!("../../public.pem").to_string(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn user_profile() -> (ContentType, &'static str) {
|
||||
(ContentType::HTML, "<p>hello</p>")
|
||||
}
|
||||
|
||||
#[get("/activities/<activity>")]
|
||||
async fn activity(activity: String) {
|
||||
dbg!(activity);
|
||||
}
|
||||
|
||||
// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social
|
||||
#[get("/.well-known/webfinger?<resource>")]
|
||||
async fn webfinger(mut db: Connection<Db>, resource: &str) -> Json<ap::WebfingerResponse> {
|
||||
println!("Webfinger request for {}", resource);
|
||||
let acct = resource.strip_prefix("acct:").unwrap();
|
||||
let (user, _) = acct.split_once("@").unwrap();
|
||||
|
||||
let user = FerriUser::by_name(user, &mut **db).await;
|
||||
dbg!(&user);
|
||||
|
||||
Json(ap::WebfingerResponse {
|
||||
subject: resource.to_string(),
|
||||
aliases: vec![
|
||||
format!("https://ferri.amy.mov/users/{}", user.id),
|
||||
format!("https://ferri.amy.mov/@{}", user.id)
|
||||
],
|
||||
links: vec![
|
||||
ap::Link {
|
||||
rel: "http://webfinger.net/rel/profile-page".to_string(),
|
||||
ty: Some("text/html".to_string()),
|
||||
href: Some(format!("https://ferri.amy.mov/@{}", user.id))
|
||||
},
|
||||
ap::Link {
|
||||
rel: "self".to_string(),
|
||||
ty: Some("application/activity+json".to_string()),
|
||||
href: Some(format!("https://ferri.amy.mov/users/{}", user.id))
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_user(acct: &str, host: &str) -> ap::Person {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://{}/.well-known/webfinger?resource=acct:{}", host, acct);
|
||||
let wf = client.get(url)
|
||||
.send()
|
||||
.await.unwrap()
|
||||
.json::<ap::WebfingerResponse>()
|
||||
.await.unwrap();
|
||||
|
||||
let actor_link = wf.links
|
||||
.iter()
|
||||
.find(|l| l.ty == Some("application/activity+json".to_string()))
|
||||
.unwrap();
|
||||
|
||||
let href = actor_link.href.as_ref().unwrap();
|
||||
client.get(href)
|
||||
.header("Accept", "application/activity+json")
|
||||
.send()
|
||||
.await.unwrap()
|
||||
.json::<ap::Person>()
|
||||
.await.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PostSignature {
|
||||
date: String,
|
||||
digest: String,
|
||||
signature: String
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GetSignature {
|
||||
date: String,
|
||||
signature: String
|
||||
}
|
||||
|
||||
fn sign_get_request(key_id: String, raw_url: String) -> GetSignature {
|
||||
let url = Url::parse(&raw_url).unwrap();
|
||||
|
||||
let host = url.host_str().unwrap();
|
||||
let path = url.path();
|
||||
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!("../../private.pem")).unwrap();
|
||||
let signing_key = SigningKey::<Sha256>::new(private_key);
|
||||
|
||||
// UTC=GMT for our purposes, use it
|
||||
// RFC7231 is hardcoded to use GMT for.. some reason
|
||||
let ts = Utc::now();
|
||||
|
||||
// RFC7231 string
|
||||
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||
dbg!(&date);
|
||||
|
||||
let to_sign = format!("(request-target): get {}\nhost: {}\ndate: {}",
|
||||
path,
|
||||
host,
|
||||
date);
|
||||
|
||||
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
||||
let header = format!(
|
||||
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"",
|
||||
key_id,
|
||||
BASE64_STANDARD.encode(signature.to_bytes())
|
||||
);
|
||||
|
||||
GetSignature {
|
||||
date: date,
|
||||
signature: header
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_post_request(key_id: String, raw_url: String, body: String) -> PostSignature {
|
||||
let url = Url::parse(&raw_url).unwrap();
|
||||
|
||||
let host = url.host_str().unwrap();
|
||||
let path = url.path();
|
||||
|
||||
let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!("../../private.pem")).unwrap();
|
||||
let signing_key = SigningKey::<Sha256>::new(private_key);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(body);
|
||||
let sha256 = hasher.finalize();
|
||||
|
||||
let b64 = BASE64_STANDARD.encode(sha256);
|
||||
let digest = format!("SHA-256={}", b64);
|
||||
|
||||
// UTC=GMT for our purposes, use it
|
||||
// RFC7231 is hardcoded to use GMT for.. some reason
|
||||
let ts = Utc::now();
|
||||
|
||||
// RFC7231 string
|
||||
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||
dbg!(&date);
|
||||
|
||||
let to_sign = format!("(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}",
|
||||
path,
|
||||
host,
|
||||
date,
|
||||
digest);
|
||||
|
||||
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
||||
let header = format!(
|
||||
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"",
|
||||
key_id,
|
||||
BASE64_STANDARD.encode(signature.to_bytes())
|
||||
);
|
||||
|
||||
PostSignature {
|
||||
date: date,
|
||||
digest: digest,
|
||||
signature: header
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/test")]
|
||||
async fn index() -> &'static str {
|
||||
let client = reqwest::Client::new();
|
||||
let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await;
|
||||
dbg!(&user);
|
||||
|
||||
let post = ap::CreateActivity {
|
||||
id: "https://ferri.amy.mov/activities/amy/20".to_string(),
|
||||
ty: "Create".to_string(),
|
||||
summary: "Amy create a note".to_string(),
|
||||
actor: "https://ferri.amy.mov/users/amy".to_string(),
|
||||
object: ap::Post {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
id: "https://ferri.amy.mov/users/amy/posts/20".to_string(),
|
||||
ty: "Note".to_string(),
|
||||
content: "My first post".to_string(),
|
||||
ts: "2025-04-10T10:48:11Z".to_string(),
|
||||
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
|
||||
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
|
||||
},
|
||||
ts: "2025-04-10T10:48:11Z".to_string(),
|
||||
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
|
||||
cc: vec![],
|
||||
};
|
||||
|
||||
let key_id = "https://ferri.amy.mov/users/amy#main-key".to_string();
|
||||
let document = serde_json::to_string(&post).unwrap();
|
||||
let signature = sign_post_request(key_id, user.inbox.clone(), document);
|
||||
dbg!(&signature);
|
||||
|
||||
let follow_res = client.post(user.inbox)
|
||||
.header("Content-Type", "application/activity+json")
|
||||
.header("Accept", "application/activity+json")
|
||||
.header("Date", signature.date)
|
||||
.header("Digest", signature.digest)
|
||||
.header("Signature", signature.signature)
|
||||
.json(&post)
|
||||
.send()
|
||||
.await.unwrap()
|
||||
.text()
|
||||
.await.unwrap();
|
||||
|
||||
println!("{}", follow_res);
|
||||
|
||||
"Hello, world!"
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, FromForm)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Status {
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthenticatedUser {
|
||||
id: String
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum LoginError {
|
||||
InvalidData,
|
||||
UsernameDoesNotExist,
|
||||
WrongPassword
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'a> FromRequest<'a> for AuthenticatedUser {
|
||||
type Error = LoginError;
|
||||
async fn from_request(request: &'a Request<'_>) -> Outcome<AuthenticatedUser, LoginError> {
|
||||
let token = request.headers().get_one("Authorization").unwrap();
|
||||
Outcome::Success(AuthenticatedUser {
|
||||
id: token.to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/statuses", data="<status>")]
|
||||
async fn new_status(mut db: Connection<Db>, status: Form<Status>, user: AuthenticatedUser) {
|
||||
let user = FerriUser::by_name(&user.id, &mut **db).await;
|
||||
let post_id = Uuid::new_v4();
|
||||
let uri = format!("https://ferri.amy.mov/users/amy/posts/{}", post_id);
|
||||
|
||||
let post = sqlx::query!(r#"
|
||||
INSERT INTO post (id, user_id, content)
|
||||
VALUES (?1, ?2, ?3)
|
||||
RETURNING *
|
||||
"#, uri, user.id, status.status).fetch_one(&mut **db).await;
|
||||
|
||||
dbg!(user, status, post);
|
||||
}
|
||||
|
||||
pub fn launch() -> Rocket<Build> {
|
||||
build().attach(Db::init())
|
||||
.mount("/", routes![
|
||||
index,
|
||||
inbox,
|
||||
post_inbox,
|
||||
outbox,
|
||||
user,
|
||||
user_profile,
|
||||
get_post,
|
||||
followers,
|
||||
following,
|
||||
activity,
|
||||
webfinger
|
||||
])
|
||||
.mount("/api/v1", routes![
|
||||
new_status
|
||||
])
|
||||
}
|
96
flake.lock
generated
Normal file
96
flake.lock
generated
Normal file
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1743550720,
|
||||
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1743583204,
|
||||
"narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1743296961,
|
||||
"narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1736320768,
|
||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1743906877,
|
||||
"narHash": "sha256-Thah1oU8Vy0gs9bh5QhNcQh1iuQiowMnZPbrkURonZA=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "9d00c6b69408dd40d067603012938d9fbe95cfcd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
40
flake.nix
Normal file
40
flake.nix
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
description = "Description for the project";
|
||||
|
||||
inputs = {
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
|
||||
|
||||
perSystem = { config, self', inputs', pkgs, system, ... }: {
|
||||
_module.args.pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
|
||||
overlays = [
|
||||
(import inputs.rust-overlay)
|
||||
];
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
env = {
|
||||
DATABASE_URL = "sqlite:ferri.db";
|
||||
};
|
||||
|
||||
packages = with pkgs; [
|
||||
sqlx-cli
|
||||
(rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ ];
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
7
migrations/20250410112126_add_actor.sql
Normal file
7
migrations/20250410112126_add_actor.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS actor
|
||||
(
|
||||
-- URI
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
inbox TEXT NOT NULL,
|
||||
outbox TEXT NOT NULL
|
||||
);
|
9
migrations/20250410112325_add_follow.sql
Normal file
9
migrations/20250410112325_add_follow.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS follow
|
||||
(
|
||||
-- Activity ID
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
follower_id TEXT NOT NULL,
|
||||
followed_id TEXT NOT NULL,
|
||||
FOREIGN KEY(follower_id) REFERENCES actor(id),
|
||||
FOREIGN KEY(followed_id) REFERENCES actor(id)
|
||||
);
|
9
migrations/20250410121119_add_user.sql
Normal file
9
migrations/20250410121119_add_user.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS user
|
||||
(
|
||||
-- Username
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY(actor_id) REFERENCES actor(id)
|
||||
);
|
9
migrations/20250410182845_add_post.sql
Normal file
9
migrations/20250410182845_add_post.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS post
|
||||
(
|
||||
-- Uri
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY(user_id) REFERENCES user(id)
|
||||
);
|
Loading…
Add table
Reference in a new issue