chore: updated apb things, restructured a bit

This commit is contained in:
əlemi 2024-04-11 00:29:32 +02:00
parent a9229adec8
commit 86e84d88aa
Signed by: alemi
GPG key ID: A4895B84D311642C
15 changed files with 237 additions and 288 deletions

View file

@ -99,7 +99,13 @@ async fn main() {
let ctx = server::Context::new(db, args.domain)
.await.expect("failed creating server context");
let router = routes::activitypub::router().with_state(ctx);
use routes::activitypub::ActivityPubRouter;
use routes::mastodon::MastodonRouter;
let router = axum::Router::new()
.ap_routes()
.mastodon_routes() // no-op if mastodon feature is disabled
.with_state(ctx);
// run our app with hyper, listening locally on port 3000
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
@ -113,7 +119,6 @@ async fn main() {
}
async fn fetch(db: &sea_orm::DatabaseConnection, uri: &str, save: bool) -> reqwest::Result<()> {
use apb::{Base, Object};

View file

@ -1,4 +1,4 @@
use crate::{routes::activitypub::PUBLIC_TARGET, model::{config, credential}};
use crate::model::{config, credential};
use super::{activity, object, user, Audience};
use openssl::rsa::Rsa;
use sea_orm::IntoActiveModel;
@ -10,7 +10,7 @@ pub async fn faker(db: &sea_orm::DatabaseConnection, domain: String, count: u64)
let test_user = super::user::Model {
id: format!("{domain}/users/test"),
name: Some("μpub".into()),
domain: crate::routes::activitypub::domain(&domain),
domain: clean_domain(&domain),
preferred_username: "test".to_string(),
summary: Some("hello world! i'm manually generated but served dynamically from db! check progress at https://git.alemi.dev/upub.git".to_string()),
following: None,
@ -64,7 +64,7 @@ pub async fn faker(db: &sea_orm::DatabaseConnection, domain: String, count: u64)
comments: Set(0),
likes: Set(0),
shares: Set(0),
to: Set(Audience(vec![PUBLIC_TARGET.to_string()])),
to: Set(Audience(vec![apb::target::PUBLIC.to_string()])),
bto: Set(Audience::default()),
cc: Set(Audience(vec![])),
bcc: Set(Audience::default()),
@ -77,7 +77,7 @@ pub async fn faker(db: &sea_orm::DatabaseConnection, domain: String, count: u64)
object: Set(Some(format!("{domain}/objects/{oid}"))),
target: Set(None),
published: Set(chrono::Utc::now() - std::time::Duration::from_secs(60*i)),
to: Set(Audience(vec![PUBLIC_TARGET.to_string()])),
to: Set(Audience(vec![apb::target::PUBLIC.to_string()])),
bto: Set(Audience::default()),
cc: Set(Audience(vec![])),
bcc: Set(Audience::default()),
@ -86,3 +86,10 @@ pub async fn faker(db: &sea_orm::DatabaseConnection, domain: String, count: u64)
Ok(())
}
fn clean_domain(domain: &str) -> String {
domain
.replace("http://", "")
.replace("https://", "")
.replace('/', "")
}

View file

@ -1,7 +1,6 @@
use sea_orm::entity::prelude::*;
use apb::{Collection, Actor, PublicKey, ActorType};
use crate::routes::activitypub;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "users")]
@ -40,7 +39,7 @@ pub struct Model {
impl Model {
pub fn new(object: &impl Actor) -> Result<Self, super::FieldError> {
let ap_id = object.id().ok_or(super::FieldError("id"))?.to_string();
let (domain, preferred_username) = activitypub::split_id(&ap_id);
let (domain, preferred_username) = split_user_id(&ap_id);
Ok(Model {
id: ap_id, preferred_username, domain,
actor_type: object.actor_type().ok_or(super::FieldError("type"))?,
@ -121,3 +120,13 @@ impl Related<super::addressing::Entity> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
fn split_user_id(id: &str) -> (String, String) {
let clean = id
.replace("http://", "")
.replace("https://", "");
let mut splits = clean.split('/');
let first = splits.next().unwrap_or("");
let last = splits.last().unwrap_or(first);
(first.to_string(), last.to_string())
}

View file

@ -0,0 +1,25 @@
use apb::{ActorMut, BaseMut, ObjectMut, PublicKeyMut};
use axum::{extract::State, http::StatusCode, Json};
use crate::{server::Context, url};
use super::jsonld::LD;
pub async fn view(State(ctx): State<Context>) -> Result<Json<serde_json::Value>, StatusCode> {
Ok(Json(
serde_json::Value::new_object()
.set_id(Some(&url!(ctx, "")))
.set_actor_type(Some(apb::ActorType::Application))
.set_name(Some("μpub"))
.set_summary(Some("micro social network, federated"))
.set_published(Some(ctx.app().created))
.set_public_key(apb::Node::object(
serde_json::Value::new_object()
.set_id(Some(&url!(ctx, "#main-key")))
.set_owner(Some(&url!(ctx, "")))
.set_public_key_pem(&ctx.app().public_key)
))
.ld_context()
))
}

View file

@ -0,0 +1,48 @@
use axum::{http::StatusCode, extract::State, Json};
use rand::Rng;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{model, server::Context};
#[derive(Debug, Clone, serde::Deserialize)]
pub struct LoginForm {
email: String,
password: String,
}
pub async fn login(State(ctx): State<Context>, Json(login): Json<LoginForm>) -> Result<Json<serde_json::Value>, StatusCode> {
// TODO salt the pwd
match model::credential::Entity::find()
.filter(Condition::all()
.add(model::credential::Column::Email.eq(login.email))
.add(model::credential::Column::Password.eq(sha256::digest(login.password)))
)
.one(ctx.db())
.await
{
Ok(Some(x)) => {
// TODO should probably use crypto-safe rng
let token : String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(128)
.map(char::from)
.collect();
model::session::Entity::insert(
model::session::ActiveModel {
id: sea_orm::ActiveValue::Set(token.clone()),
actor: sea_orm::ActiveValue::Set(x.id),
expires: sea_orm::ActiveValue::Set(chrono::Utc::now() + std::time::Duration::from_secs(3600 * 6)),
}
)
.exec(ctx.db())
.await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::Value::String(token)))
},
Ok(None) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("error querying db for user credentials: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View file

@ -3,7 +3,7 @@ use sea_orm::{ColumnTrait, Condition, EntityTrait, Order, QueryFilter, QueryOrde
use crate::{server::auth::{AuthIdentity, Identity}, errors::UpubError, model, server::Context, url};
use super::{activity::ap_activity, jsonld::LD, JsonLD, Pagination, PUBLIC_TARGET};
use super::{activity::ap_activity, jsonld::LD, JsonLD, Pagination};
pub async fn get(
@ -20,7 +20,7 @@ pub async fn page(
let limit = page.batch.unwrap_or(20).min(50);
let offset = page.offset.unwrap_or(0);
let mut condition = Condition::any()
.add(model::addressing::Column::Actor.eq(PUBLIC_TARGET));
.add(model::addressing::Column::Actor.eq(apb::target::PUBLIC));
if let Identity::Local(user) = auth {
condition = condition
.add(model::addressing::Column::Actor.eq(user));

View file

@ -3,34 +3,33 @@ pub mod inbox;
pub mod outbox;
pub mod object;
pub mod activity;
pub mod application;
pub mod auth;
pub mod well_known;
pub mod router;
pub mod jsonld;
pub use jsonld::JsonLD;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router};
use rand::Rng;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use axum::{http::StatusCode, response::IntoResponse, routing::{get, post}, Router};
use apb::{PublicKeyMut, ActorMut, ActorType, Link, Object, ObjectMut, BaseMut, Node};
use crate::{model, server::Context, url};
pub trait ActivityPubRouter {
fn ap_routes(self) -> Self;
}
use self::jsonld::LD;
pub fn router() -> Router<crate::server::Context> {
impl ActivityPubRouter for Router<crate::server::Context> {
fn ap_routes(self) -> Self {
use crate::routes::activitypub as ap; // TODO use self ?
Router::new()
self
// core server inbox/outbox, maybe for feeds? TODO do we need these?
.route("/", get(ap::view))
.route("/", get(ap::application::view))
// TODO shared inboxes and instance stream will come later, just use users *boxes for now
.route("/inbox", get(ap::inbox::get))
// .route("/inbox", post(ap::inbox::post))
// .route("/outbox", get(ap::outbox::get))
// .route("/outbox", get(ap::outbox::post))
// AUTH routes
.route("/auth", post(ap::auth))
.route("/auth", post(ap::auth::login))
// .well-known and discovery
.route("/.well-known/webfinger", get(ap::well_known::webfinger))
.route("/.well-known/host-meta", get(ap::well_known::host_meta))
@ -52,41 +51,8 @@ pub fn router() -> Router<crate::server::Context> {
.route("/activities/:id", get(ap::activity::view))
.route("/objects/:id", get(ap::object::view))
}
pub trait Addressed : Object {
fn addressed(&self) -> Vec<String>;
}
impl Addressed for serde_json::Value {
fn addressed(&self) -> Vec<String> {
let mut to : Vec<String> = self.to().map(|x| x.href().to_string()).collect();
to.append(&mut self.bto().map(|x| x.href().to_string()).collect());
to.append(&mut self.cc().map(|x| x.href().to_string()).collect());
to.append(&mut self.bcc().map(|x| x.href().to_string()).collect());
to
}
}
pub const PUBLIC_TARGET : &str = "https://www.w3.org/ns/activitystreams#Public";
pub fn split_id(id: &str) -> (String, String) {
let clean = id
.replace("http://", "")
.replace("https://", "");
let mut splits = clean.split('/');
let first = splits.next().unwrap_or("");
let last = splits.last().unwrap_or(first);
(first.to_string(), last.to_string())
}
pub fn domain(domain: &str) -> String {
domain
.replace("http://", "")
.replace("https://", "")
.replace('/', "")
}
#[derive(Debug, serde::Deserialize)]
// TODO i don't really like how pleroma/mastodon do it actually, maybe change this?
pub struct Pagination {
@ -104,87 +70,3 @@ impl IntoResponse for CreationResult {
.into_response()
}
}
pub async fn view(State(ctx): State<Context>) -> Result<Json<serde_json::Value>, StatusCode> {
Ok(Json(
serde_json::Value::new_object()
.set_id(Some(&url!(ctx, "")))
.set_actor_type(Some(ActorType::Application))
.set_name(Some("μpub"))
.set_summary(Some("micro social network, federated"))
.set_published(Some(ctx.app().created))
.set_public_key(Node::object(
serde_json::Value::new_object()
.set_id(Some(&url!(ctx, "#main-key")))
.set_owner(Some(&url!(ctx, "")))
.set_public_key_pem(&ctx.app().public_key)
))
.ld_context()
))
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct LoginForm {
email: String,
password: String,
}
pub async fn auth(State(ctx): State<Context>, Json(login): Json<LoginForm>) -> Result<Json<serde_json::Value>, StatusCode> {
// TODO salt the pwd
match model::credential::Entity::find()
.filter(Condition::all()
.add(model::credential::Column::Email.eq(login.email))
.add(model::credential::Column::Password.eq(sha256::digest(login.password)))
)
.one(ctx.db())
.await
{
Ok(Some(x)) => {
// TODO should probably use crypto-safe rng
let token : String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(128)
.map(char::from)
.collect();
model::session::Entity::insert(
model::session::ActiveModel {
id: sea_orm::ActiveValue::Set(token.clone()),
actor: sea_orm::ActiveValue::Set(x.id),
expires: sea_orm::ActiveValue::Set(chrono::Utc::now() + std::time::Duration::from_secs(3600 * 6)),
}
)
.exec(ctx.db())
.await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::Value::String(token)))
},
Ok(None) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("error querying db for user credentials: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[axum::async_trait]
pub trait APOutbox {
async fn create_note(&self, uid: String, object: serde_json::Value) -> crate::Result<String>;
async fn create(&self, uid: String, activity: serde_json::Value) -> crate::Result<String>;
async fn like(&self, uid: String, activity: serde_json::Value) -> crate::Result<String>;
async fn follow(&self, uid: String, activity: serde_json::Value) -> crate::Result<String>;
async fn accept(&self, uid: String, activity: serde_json::Value) -> crate::Result<String>;
async fn reject(&self, _uid: String, _activity: serde_json::Value) -> crate::Result<String>;
async fn undo(&self, uid: String, activity: serde_json::Value) -> crate::Result<String>;
}
#[axum::async_trait]
pub trait APInbox {
async fn create(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn like(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn follow(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn accept(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn reject(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn undo(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn delete(&self, activity: serde_json::Value) -> crate::Result<()>;
async fn update(&self, activity: serde_json::Value) -> crate::Result<()>;
}

View file

@ -1,46 +0,0 @@
use axum::{routing::{get, post}, Router};
use sea_orm::DatabaseConnection;
use crate::routes::activitypub as ap;
pub async fn serve(db: DatabaseConnection, domain: String) -> std::io::Result<()> {
// build our application with a single route
let app = Router::new()
// core server inbox/outbox, maybe for feeds? TODO do we need these?
.route("/", get(ap::view))
// TODO shared inboxes and instance stream will come later, just use users *boxes for now
.route("/inbox", get(ap::inbox::get))
// .route("/inbox", post(ap::inbox::post))
// .route("/outbox", get(ap::outbox::get))
// .route("/outbox", get(ap::outbox::post))
// AUTH routes
.route("/auth", post(ap::auth))
// .well-known and discovery
.route("/.well-known/webfinger", get(ap::well_known::webfinger))
.route("/.well-known/host-meta", get(ap::well_known::host_meta))
.route("/.well-known/nodeinfo", get(ap::well_known::nodeinfo_discovery))
.route("/nodeinfo/:version", get(ap::well_known::nodeinfo))
// actor routes
.route("/users/:id", get(ap::user::view))
.route("/users/:id/inbox", post(ap::user::inbox::post))
.route("/users/:id/inbox", get(ap::user::inbox::get))
.route("/users/:id/inbox/page", get(ap::user::inbox::page))
.route("/users/:id/outbox", post(ap::user::outbox::post))
.route("/users/:id/outbox", get(ap::user::outbox::get))
.route("/users/:id/outbox/page", get(ap::user::outbox::page))
.route("/users/:id/followers", get(ap::user::following::get::<false>))
.route("/users/:id/followers/page", get(ap::user::following::page::<false>))
.route("/users/:id/following", get(ap::user::following::get::<true>))
.route("/users/:id/following/page", get(ap::user::following::page::<true>))
// specific object routes
.route("/activities/:id", get(ap::activity::view))
.route("/objects/:id", get(ap::object::view))
.with_state(
crate::server::Context::new(db, domain).await.expect("could not create server state")
);
// run our app with hyper, listening locally on port 3000
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app)
.await
}

View file

@ -1,8 +1,8 @@
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
use sea_orm::{ColumnTrait, Condition, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect};
use apb::{ActivityType, ObjectType, Base, BaseType};
use crate::{routes::activitypub::{activity::ap_activity, jsonld::LD, APInbox, JsonLD, Pagination}, server::{Context, auth::{AuthIdentity, Identity}}, errors::UpubError, model, url};
use apb::{server::Inbox, ActivityType, Base, BaseType, ObjectType};
use crate::{routes::activitypub::{activity::ap_activity, jsonld::LD, JsonLD, Pagination}, server::{Context, auth::{AuthIdentity, Identity}}, errors::UpubError, model, url};
pub async fn get(
State(ctx): State<Context>,

View file

@ -1,8 +1,8 @@
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
use sea_orm::{EntityTrait, Order, QueryOrder, QuerySelect};
use apb::{AcceptType, ActivityMut, ActivityType, Base, BaseType, Node, ObjectType, RejectType};
use crate::{routes::activitypub::{jsonld::LD, APOutbox, CreationResult, JsonLD, Pagination}, server::auth::{AuthIdentity, Identity}, errors::UpubError, model, server::Context, url};
use apb::{server::Outbox, AcceptType, ActivityMut, ActivityType, Base, BaseType, Node, ObjectType, RejectType};
use crate::{routes::activitypub::{jsonld::LD, CreationResult, JsonLD, Pagination}, server::auth::{AuthIdentity, Identity}, errors::UpubError, model, server::Context, url};
pub async fn get(
State(ctx): State<Context>,

View file

@ -1,12 +1,15 @@
use axum::{http::StatusCode, routing::{delete, get, patch, post}, Router};
use crate::server::Context;
#[allow(unused)]
async fn todo() -> StatusCode { StatusCode::NOT_IMPLEMENTED }
#[allow(unused)]
pub async fn mastodon_api_routes(router: Router<Context>) -> Router<Context> {
router.nest(
pub trait MastodonRouter {
fn mastodon_routes(self) -> Self;
}
impl MastodonRouter for Router<Context> {
fn mastodon_routes(self) -> Self {
self.nest(
// TODO Oauth is just under /oauth
"/api/v1", Router::new()
.route("/apps", post(todo)) // create an application
@ -64,6 +67,6 @@ pub async fn mastodon_api_routes(router: Router<Context>) -> Router<Context> {
.route("/profile/avatar", delete(todo))
.route("/profile/header", delete(todo))
.route("/statuses", post(todo))
)
}
}

View file

@ -5,3 +5,12 @@ pub mod web;
#[cfg(feature = "mastodon")]
pub mod mastodon;
#[cfg(not(feature = "mastodon"))]
pub mod mastodon {
pub trait MastodonRouter {
fn mastodon_routes(self) -> Self { self }
}
impl MastodonRouter for axum::Router<crate::server::Context> {}
}

View file

@ -4,7 +4,7 @@ use apb::{BaseMut, CollectionMut, CollectionPageMut};
use openssl::rsa::Rsa;
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, SelectColumns, Set};
use crate::{model, routes::activitypub::{jsonld::LD, PUBLIC_TARGET}};
use crate::{model, routes::activitypub::jsonld::LD};
use super::{dispatcher::Dispatcher, fetcher::Fetcher};
@ -176,7 +176,7 @@ impl Context {
.iter()
.filter(|to| !to.is_empty())
.filter(|to| Context::server(to) != self.base())
.filter(|to| to != &PUBLIC_TARGET)
.filter(|to| to != &apb::target::PUBLIC)
.map(|to| model::delivery::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
actor: Set(from.to_string()),

View file

@ -1,13 +1,16 @@
use apb::{Activity, Base, Object};
use apb::{target::Addressed, Activity, Base, Object};
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use crate::{errors::{LoggableError, UpubError}, model, routes::activitypub::{APInbox, Addressed}};
use crate::{errors::{LoggableError, UpubError}, model};
use super::Context;
#[axum::async_trait]
impl APInbox for Context {
impl apb::server::Inbox for Context {
type Error = UpubError;
type Activity = serde_json::Value;
async fn create(&self, activity: serde_json::Value) -> crate::Result<()> {
let activity_model = model::activity::Model::new(&activity)?;
let activity_targets = activity.addressed();

View file

@ -1,13 +1,17 @@
use apb::{Activity, ActivityMut, BaseMut, Node, ObjectMut};
use apb::{target::Addressed, Activity, ActivityMut, BaseMut, Node, ObjectMut};
use sea_orm::{EntityTrait, IntoActiveModel, Set};
use crate::{errors::UpubError, model, routes::activitypub::{APOutbox, Addressed}};
use crate::{errors::UpubError, model};
use super::Context;
#[axum::async_trait]
impl APOutbox for Context {
impl apb::server::Outbox for Context {
type Error = UpubError;
type Object = serde_json::Value;
type Activity = serde_json::Value;
async fn create_note(&self, uid: String, object: serde_json::Value) -> crate::Result<String> {
let oid = self.oid(uuid::Uuid::new_v4().to_string());
let aid = self.aid(uuid::Uuid::new_v4().to_string());