Compare commits

..

No commits in common. "dev" and "tci" have entirely different histories.
dev ... tci

103 changed files with 990 additions and 11683 deletions

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
/target
/apb/target
/web/dist

8
.tci
View file

@ -1,7 +1,7 @@
#!/bin/bash
echo "building release binary"
cargo build --release --all-features -j 1 # limit memory usage
cargo build --release --all-features
echo "stopping service"
systemctl --user stop upub
echo "installing new binary"
@ -10,10 +10,4 @@ echo "migrating database"
/opt/bin/upub --db "sqlite:///srv/tci/upub.db" --domain https://feditest.alemi.dev migrate
echo "restarting service"
systemctl --user start upub
echo "rebuilding frontend"
cd web
CARGO_BUILD_JOBS=1 /opt/bin/trunk build --release --public-url 'https://feditest.alemi.dev/web'
echo "deploying frontend"
rm /srv/http/feditest/web/*
mv ./dist/* /srv/http/feditest/web/
echo "done"

5023
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
[workspace]
members = ["apb", "web", "mdhtml"]
members = ["apb"]
[package]
name = "upub"
version = "0.2.0"
version = "0.1.0"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "Traits and types to handle ActivityPub objects"
@ -24,32 +24,26 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.8", features = ["v4"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_default = "0.1"
serde-inline-default = "0.2"
toml = "0.8"
mdhtml = { path = "mdhtml", features = ["markdown"] }
jrd = "0.1"
tracing = "0.1"
tracing-subscriber = "0.3"
clap = { version = "4.5", features = ["derive"] }
futures = "0.3"
tokio = { version = "1.35", features = ["full"] } # TODO slim this down
sea-orm = { version = "0.12", features = ["macros", "sqlx-sqlite", "runtime-tokio-rustls"] }
reqwest = { version = "0.12", features = ["json"] }
axum = "0.7"
tower-http = { version = "0.5", features = ["cors", "trace"] }
apb = { path = "apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub"] }
apb = { path = "apb", features = ["unstructured", "fetch", "orm"] }
# nodeinfo = "0.0.2" # the version on crates.io doesn't re-export necessary types to build the struct!!!
nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "e865094804" }
http-signature-normalization = "0.7.0"
# migrations
sea-orm-migration = { version = "0.12", optional = true }
# mastodon
mastodon-async-entities = { version = "1.1.0", optional = true }
time = { version = "0.3", features = ["serde"], optional = true }
async-recursion = "1.1"
[features]
default = ["migrations", "cli"]
cli = []
default = ["faker", "migrations", "mastodon"]
faker = []
migrations = ["dep:sea-orm-migration"]
mastodon = ["dep:mastodon-async-entities", "dep:time"]

View file

@ -1,44 +1,14 @@
# μpub
> micro social network, federated
![screenshot of upub simple frontend](https://cdn.alemi.dev/proj/upub/fe/20240514.png)
μpub aims to be a fast, lightweight and secure [ActivityPub](https://www.w3.org/TR/activitypub/) server
μpub aims to be a private, lightweight, modular and **secure** [ActivityPub](https://www.w3.org/TR/activitypub/) server
μpub is currently being developed and can do some basic things, like posting notes, follows and likes
* follow development [in the dedicated matrix room](https://matrix.to/#/#upub:alemi.dev)
μpub is usable as a very simple ActivityPub project: it has a home and server timeline, it allows to browse threads, star notes and leave replies, it renders remote media of any kind and can be used to browse and follow remote users
all interactions happen with ActivityPub's client-server methods (basically POST your activities to your outbox), with [appropriate extensions](https://ns.alemi.dev/as): **μpub doesn't want to invent another API**!
development is still active, so expect more stuff to come! since most fediverse software uses Mastodon's API, μpub plans to implement it as an optional feature, becoming eventually compatible with most existing frontends and mobile applications, but focus right now is on producing something specific to μpub needs
all interactions must happen with ActivityPub's client-server methods (basically POST your activities to your outbox)
a test instance is _usually_ available at [feditest.alemi.dev](https://feditest.alemi.dev)
## about security
most activitypub implementations don't really validate fetches: knowing an activity/object id will allow anyone to resolve it on most fedi software. this is of course unacceptable: "security through obscurity" just doesn't work
μpub correctly and rigorously implements and enforces access control on each object based on its addressing
most instances will have "authorized fetch" which kind of makes the issue less bad, but anyone can host an actor, have any server download their pubkey and then start fetching
μpub may be considered to have "authorized fetch" permanently on, except it depends on each post:
* all posts marked public (meaning, addressed to "https://www.w3.org/ns/activitystreams#Public"), will be fetchable without any authorization
* all posts not public will require explicit addressing and authentication: for example if post A is addressed to example.net/actor
* anonymous fetchers will receive 404 on GET /posts/A
* local users must authenticate and will be given said post only if it's addressed to them
* remote servers will be given access to all posts from any of their users once they have authenticated themselves (with http signing)
note that followers get expanded: addressing to example.net/actor/followers will address to anyone following actor that the server knows of, at that time
## contributing
all help is extremely welcome! development mostly happens on [moonlit.technology](https://moonlit.technology/alemi/upub.git), but there's a [github mirror](https://github.com/alemidev/upub) available too
if you prefer a forge-less development you can browse the repo on [my cgit](https://git.alemi.dev/upub.git), and send me patches on any contact listed on [my site](https://alemi.dev/about/contacts)
don't hesitate to get in touch, i'd be thrilled to showcase the project to you!
## progress
- [x] barebone actors
@ -48,29 +18,11 @@ don't hesitate to get in touch, i'd be thrilled to showcase the project to you!
- [x] process barebones inbox
- [x] process barebones outbox
- [x] http signatures
- [x] privacy, targets, scopes
- [x] simple web client
- [x] announce (boosts)
- [x] threads
- [x] remote media
- [x] editing via api
- [x] advanced composer
- [x] api for fetching
- [x] like, share, reply via frontend
- [x] backend config
- [x] frontend config
- [ ] mentions, notifications
- [ ] mastodon-like search bar
- [ ] polls
- [ ] better editing via web frontend
- [ ] remote media proxy
- [ ] upload media
- [ ] hashtags
- [ ] public vs unlisted for discovery
- [ ] user fields
- [ ] lists
- [ ] full mastodon api
- [ ] optimize `addressing` database schema
- [ ] privacy, targets, scopes
- [ ] client api (mastodon/pleroma)
- [ ] hashtags, discovery
- [ ] a custom frontend maybe?
- [ ] more optimized database schema
## what about the name?
μpub (or simply `upub`) means "[micro](https://en.wikipedia.org/wiki/International_System_of_Units#Prefixes)-pub", but could also be read "upub", "you-pub" or "mu-pub"
μpub, sometimes stylyzed `upub`, is pronounced `mu-pub` (the `μ` stands for [micro](https://en.wikipedia.org/wiki/International_System_of_Units#Prefixes))

View file

@ -24,15 +24,8 @@ sea-orm = { version = "0.12", optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }
[features]
default = ["activitypub-miscellaneous-terms"]
# extensions
activitypub-miscellaneous-terms = [] # https://swicg.github.io/miscellany/
activitypub-counters = [] # https://ns.alemi.dev/as/counters/#
activitypub-fe = [] # https://ns.alemi.dev/as/fe/#
litepub = [] # incomplete, https://litepub.social/
# builtin utils
default = []
orm = ["dep:sea-orm"]
fetch = ["dep:reqwest"]
# providers
unstructured = ["dep:serde_json"]
#TODO eventually also make a structured base?
# TODO eventually also make a structured base?

View file

@ -15,7 +15,7 @@ impl PublicKey for serde_json::Value {
crate::getter! { owner -> &str }
fn public_key_pem(&self) -> &str {
self.get("publicKeyPem").map(|x| x.as_str().unwrap_or_default()).unwrap_or_default()
self.get("publicKeyPem").unwrap().as_str().unwrap()
}
}

View file

@ -113,7 +113,7 @@ pub use types::{
offer::{Offer, OfferMut, OfferType},
reject::{Reject, RejectMut, RejectType},
},
actor::{Actor, ActorMut, ActorType, Endpoints, EndpointsMut},
actor::{Actor, ActorMut, ActorType},
collection::{
Collection, CollectionMut, CollectionType,
page::{CollectionPage, CollectionPageMut}

View file

@ -115,12 +115,6 @@ macro_rules! getter {
}
};
($name:ident::$rename:ident -> bool) => {
fn $name(&self) -> Option<bool> {
self.get(stringify!($rename))?.as_bool()
}
};
($name:ident::$rename:ident -> &str) => {
fn $name(&self) -> Option<&str> {
self.get(stringify!($rename))?.as_str()
@ -212,17 +206,6 @@ macro_rules! setter {
}
};
($name:ident::$rename:ident -> bool) => {
paste::item! {
fn [< set_$name >](mut self, val: Option<bool>) -> Self {
$crate::macros::set_maybe_value(
&mut self, stringify!($rename), val.map(|x| serde_json::Value::Bool(x))
);
self
}
}
};
($name:ident -> &str) => {
paste::item! {
fn [< set_$name >](mut self, val: Option<&str>) -> Self {
@ -338,9 +321,9 @@ pub fn set_maybe_node(obj: &mut serde_json::Value, key: &str, node: crate::Node<
obj, key, Some(serde_json::Value::String(l.href().to_string())),
);
},
crate::Node::Array(arr) => {
crate::Node::Array(_) => {
set_maybe_value(
obj, key, Some(serde_json::Value::Array(arr.into())),
obj, key, Some(serde_json::Value::Array(node.into_iter().collect())),
);
},
crate::Node::Empty => {

View file

@ -1,9 +1,9 @@
/// ActivityPub object node, representing either nothing, something, a link to something or
/// multiple things
pub enum Node<T : super::Base> {
Array(std::collections::VecDeque<T>), // TODO would be cool to make it Box<[T]> so that Node is just a ptr
Array(Vec<T>), // TODO would be cool to make it Box<[T]> so that Node is just a ptr
Object(Box<T>),
Link(Box<dyn crate::Link + Sync + Send>), // TODO feature flag to toggle these maybe?
Link(Box<dyn super::Link>),
Empty,
}
@ -16,9 +16,6 @@ impl<T : super::Base> From<Option<T>> for Node<T> {
}
}
// TODO how do i move out of the box for a moment? i need to leave it uninitialized while i update
// the value and then put it back, i think it should be safe to do so! but i'm not sure how, so i'm
// using a clone (expensive but simple solution)
impl<T : super::Base + Clone> Iterator for Node<T> {
type Item = T;
@ -26,7 +23,7 @@ impl<T : super::Base + Clone> Iterator for Node<T> {
let x = match self {
Self::Empty => return None,
Self::Link(_) => return None,
Self::Array(arr) => return arr.pop_front(), // TODO weird that we iter in reverse
Self::Array(arr) => return arr.pop(), // TODO weird that we iter in reverse
Self::Object(x) => *x.clone(), // TODO needed because next() on object can't get value without owning
};
*self = Self::Empty;
@ -40,7 +37,7 @@ impl<T : super::Base> Node<T> {
match self {
Node::Empty | Node::Link(_) => None,
Node::Object(x) => Some(x),
Node::Array(v) => v.front(),
Node::Array(v) => v.last(), // TODO so it's coherent with next(), still weird tho!
}
}
@ -49,12 +46,12 @@ impl<T : super::Base> Node<T> {
match self {
Node::Empty | Node::Link(_) => None,
Node::Object(x) => Some(*x),
Node::Array(mut v) => v.pop_front(),
Node::Array(mut v) => v.pop(), // TODO so it's coherent with next(), still weird tho!
}
}
/// true only if Node is empty
pub fn is_nothing(&self) -> bool {
pub fn is_empty(&self) -> bool {
matches!(self, Node::Empty)
}
@ -73,12 +70,6 @@ impl<T : super::Base> Node<T> {
matches!(self, Node::Array(_))
}
/// true only if Node is empty
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// returns number of contained items (links count as items for len)
pub fn len(&self) -> usize {
match self {
@ -92,10 +83,9 @@ impl<T : super::Base> Node<T> {
/// returns id of object: url for link, id for object, None if empty or array
pub fn id(&self) -> Option<String> {
match self {
Node::Empty => None,
Node::Empty | Node::Array(_) => None,
Node::Link(uri) => Some(uri.href().to_string()),
Node::Object(obj) => Some(obj.id()?.to_string()),
Node::Array(arr) => Some(arr.front()?.id()?.to_string()),
Node::Object(obj) => obj.id().map(|x| x.to_string()),
}
}
}
@ -134,7 +124,7 @@ impl Node<serde_json::Value> {
}
pub fn array(values: Vec<serde_json::Value>) -> Self {
Node::Array(values.into())
Node::Array(values)
}
#[cfg(feature = "fetch")]
@ -175,7 +165,7 @@ impl From<serde_json::Value> for Node<serde_json::Value> {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::String(uri) => Node::Link(Box::new(uri)),
serde_json::Value::Array(arr) => Node::Array(arr.into()),
serde_json::Value::Array(arr) => Node::Array(arr),
serde_json::Value::Object(_) => match value.get("href") {
None => Node::Object(Box::new(value)),
Some(_) => Node::Link(Box::new(value)),

View file

@ -8,12 +8,9 @@ pub trait Outbox {
async fn create(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn like(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn follow(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn announce(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn accept(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn reject(&self, _uid: String, _activity: Self::Activity) -> Result<String, Self::Error>;
async fn undo(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn delete(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
async fn update(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
}
#[async_trait::async_trait]
@ -21,13 +18,12 @@ pub trait Inbox {
type Activity: crate::Activity;
type Error: std::error::Error;
async fn create(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn like(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn follow(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn announce(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn accept(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn reject(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn undo(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn delete(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn update(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
async fn create(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn like(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn follow(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn accept(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn reject(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn undo(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn delete(&self, activity: Self::Activity) -> Result<(), Self::Error>;
async fn update(&self, activity: Self::Activity) -> Result<(), Self::Error>;
}

View file

@ -2,7 +2,7 @@ use crate::{Object, Link};
pub const PUBLIC : &str = "https://www.w3.org/ns/activitystreams#Public";
pub trait Addressed {
pub trait Addressed : Object {
fn addressed(&self) -> Vec<String>;
}

View file

@ -32,21 +32,8 @@ impl Base for String {
#[cfg(feature = "unstructured")]
impl Base for serde_json::Value {
fn base_type(&self) -> Option<BaseType> {
if self.is_string() {
Some(BaseType::Link(LinkType::Link))
} else {
self.get("type")?.as_str()?.try_into().ok()
}
}
fn id(&self) -> Option<&str> {
if self.is_string() {
self.as_str()
} else {
self.get("id").map(|x| x.as_str())?
}
}
crate::getter! { id -> &str }
crate::getter! { base_type -> type BaseType }
}
#[cfg(feature = "unstructured")]

View file

@ -1,13 +1,3 @@
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::strenum! {
pub enum LinkType {
Link,
Hashtag,
Mention;
};
}
#[cfg(not(feature = "activitypub-miscellaneous-terms"))]
crate::strenum! {
pub enum LinkType {
Link,
@ -47,10 +37,16 @@ impl Link for String {
impl Link for serde_json::Value {
// TODO this can fail, but it should never do!
fn href(&self) -> &str {
if self.is_string() {
self.as_str().unwrap_or("")
} else {
self.get("href").map(|x| x.as_str().unwrap_or("")).unwrap_or("")
match self {
serde_json::Value::String(x) => x,
serde_json::Value::Object(map) =>
map.get("href")
.map(|h| h.as_str().unwrap_or(""))
.unwrap_or(""),
_ => {
tracing::error!("failed getting href on invalid json Link object");
""
},
}
}

View file

@ -11,38 +11,6 @@ use offer::OfferType;
use intransitive::IntransitiveActivityType;
use ignore::IgnoreType;
#[cfg(feature = "litepub")]
crate::strenum! {
pub enum ActivityType {
Activity,
Add,
Announce,
Create,
Delete,
Dislike,
EmojiReact,
Flag,
Follow,
Join,
Leave,
Like,
Listen,
Move,
Read,
Remove,
Undo,
Update,
View;
IntransitiveActivity(IntransitiveActivityType),
Accept(AcceptType),
Ignore(IgnoreType),
Offer(OfferType),
Reject(RejectType)
};
}
#[cfg(not(feature = "litepub"))]
crate::strenum! {
pub enum ActivityType {
Activity,

View file

@ -1,18 +1,18 @@
use crate::{Node, Object, ObjectMut};
use crate::Node;
use crate::{Object, ObjectMut, PublicKey};
crate::strenum! {
pub enum ActorType {
Application,
Group,
Organization,
Person,
Service;
Person;
};
}
pub trait Actor : Object {
type PublicKey : crate::PublicKey;
type Endpoints : Endpoints;
type PublicKey : PublicKey;
fn actor_type(&self) -> Option<ActorType> { None }
fn preferred_username(&self) -> Option<&str> { None }
@ -22,48 +22,14 @@ pub trait Actor : Object {
fn followers(&self) -> Node<Self::Collection> { Node::Empty }
fn liked(&self) -> Node<Self::Collection> { Node::Empty }
fn streams(&self) -> Node<Self::Collection> { Node::Empty }
fn endpoints(&self) -> Node<Self::Endpoints> { Node::Empty }
fn endpoints(&self) -> Node<Self::Object> { Node::Empty }
fn public_key(&self) -> Node<Self::PublicKey> { Node::Empty }
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn moved_to(&self) -> Node<Self::Actor> { Node::Empty }
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn manually_approves_followers(&self) -> Option<bool> { None }
#[cfg(feature = "activitypub-fe")]
fn following_me(&self) -> Option<bool> { None }
#[cfg(feature = "activitypub-fe")]
fn followed_by_me(&self) -> Option<bool> { None }
#[cfg(feature = "activitypub-counters")]
fn followers_count(&self) -> Option<u64> { None }
#[cfg(feature = "activitypub-counters")]
fn following_count(&self) -> Option<u64> { None }
#[cfg(feature = "activitypub-counters")]
fn statuses_count(&self) -> Option<u64> { None }
// idk about this? everyone has it but AP doesn't mention it
fn discoverable(&self) -> Option<bool> { None }
}
pub trait Endpoints : Object {
/// Endpoint URI so this actor's clients may access remote ActivityStreams objects which require authentication to access. To use this endpoint, the client posts an x-www-form-urlencoded id parameter with the value being the id of the requested ActivityStreams object.
fn proxy_url(&self) -> Option<&str> { None }
/// If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, this endpoint specifies a URI at which a browser-authenticated user may obtain a new authorization grant.
fn oauth_authorization_endpoint(&self) -> Option<&str> { None }
/// If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, this endpoint specifies a URI at which a client may acquire an access token.
fn oauth_token_endpoint(&self) -> Option<&str> { None }
/// If Linked Data Signatures and HTTP Signatures are being used for authentication and authorization, this endpoint specifies a URI at which browser-authenticated users may authorize a client's public key for client to server interactions.
fn provide_client_key(&self) -> Option<&str> { None }
/// If Linked Data Signatures and HTTP Signatures are being used for authentication and authorization, this endpoint specifies a URI at which a client key may be signed by the actor's key for a time window to act on behalf of the actor in interacting with foreign servers.
fn sign_client_key(&self) -> Option<&str> { None }
/// An optional endpoint used for wide delivery of publicly addressed activities and activities sent to followers. sharedInbox endpoints SHOULD also be publicly readable OrderedCollection objects containing objects addressed to the Public special collection. Reading from the sharedInbox endpoint MUST NOT present objects which are not addressed to the Public endpoint.
fn shared_inbox(&self) -> Option<&str> { None }
}
pub trait ActorMut : ObjectMut {
type PublicKey : crate::PublicKey;
type Endpoints : Endpoints;
type PublicKey : PublicKey;
fn set_actor_type(self, val: Option<ActorType>) -> Self;
fn set_preferred_username(self, val: Option<&str>) -> Self;
@ -73,48 +39,14 @@ pub trait ActorMut : ObjectMut {
fn set_followers(self, val: Node<Self::Collection>) -> Self;
fn set_liked(self, val: Node<Self::Collection>) -> Self;
fn set_streams(self, val: Node<Self::Collection>) -> Self;
fn set_endpoints(self, val: Node<Self::Endpoints>) -> Self;
fn set_endpoints(self, val: Node<Self::Object>) -> Self; // TODO it's more complex than this!
fn set_public_key(self, val: Node<Self::PublicKey>) -> Self;
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn set_moved_to(self, val: Node<Self::Actor>) -> Self;
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn set_manually_approves_followers(self, val: Option<bool>) -> Self;
#[cfg(feature = "activitypub-fe")]
fn set_following_me(self, val: Option<bool>) -> Self;
#[cfg(feature = "activitypub-fe")]
fn set_followed_by_me(self, val: Option<bool>) -> Self;
#[cfg(feature = "activitypub-counters")]
fn set_followers_count(self, val: Option<u64>) -> Self;
#[cfg(feature = "activitypub-counters")]
fn set_following_count(self, val: Option<u64>) -> Self;
#[cfg(feature = "activitypub-counters")]
fn set_statuses_count(self, val: Option<u64>) -> Self;
fn set_discoverable(self, val: Option<bool>) -> Self;
}
pub trait EndpointsMut : ObjectMut {
/// Endpoint URI so this actor's clients may access remote ActivityStreams objects which require authentication to access. To use this endpoint, the client posts an x-www-form-urlencoded id parameter with the value being the id of the requested ActivityStreams object.
fn set_proxy_url(self, val: Option<&str>) -> Self;
/// If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, this endpoint specifies a URI at which a browser-authenticated user may obtain a new authorization grant.
fn set_oauth_authorization_endpoint(self, val: Option<&str>) -> Self;
/// If OAuth 2.0 bearer tokens [RFC6749] [RFC6750] are being used for authenticating client to server interactions, this endpoint specifies a URI at which a client may acquire an access token.
fn set_oauth_token_endpoint(self, val: Option<&str>) -> Self;
/// If Linked Data Signatures and HTTP Signatures are being used for authentication and authorization, this endpoint specifies a URI at which browser-authenticated users may authorize a client's public key for client to server interactions.
fn set_provide_client_key(self, val: Option<&str>) -> Self;
/// If Linked Data Signatures and HTTP Signatures are being used for authentication and authorization, this endpoint specifies a URI at which a client key may be signed by the actor's key for a time window to act on behalf of the actor in interacting with foreign servers.
fn set_sign_client_key(self, val: Option<&str>) -> Self;
/// An optional endpoint used for wide delivery of publicly addressed activities and activities sent to followers. sharedInbox endpoints SHOULD also be publicly readable OrderedCollection objects containing objects addressed to the Public special collection. Reading from the sharedInbox endpoint MUST NOT present objects which are not addressed to the Public endpoint.
fn set_shared_inbox(self, val: Option<&str>) -> Self;
}
#[cfg(feature = "unstructured")]
impl Actor for serde_json::Value {
type PublicKey = serde_json::Value;
type Endpoints = serde_json::Value;
crate::getter! { actor_type -> type ActorType }
crate::getter! { preferred_username::preferredUsername -> &str }
@ -125,42 +57,16 @@ impl Actor for serde_json::Value {
crate::getter! { liked -> node Self::Collection }
crate::getter! { streams -> node Self::Collection }
crate::getter! { public_key::publicKey -> node Self::PublicKey }
crate::getter! { endpoints -> node Self::Endpoints }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::getter! { moved_to::movedTo -> node Self::Actor }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::getter! { manually_approves_followers::manuallyApprovedFollowers -> bool }
#[cfg(feature = "activitypub-fe")]
crate::getter! { following_me::followingMe -> bool }
#[cfg(feature = "activitypub-fe")]
crate::getter! { followed_by_me::followedByMe -> bool }
#[cfg(feature = "activitypub-counters")]
crate::getter! { following_count::followingCount -> u64 }
#[cfg(feature = "activitypub-counters")]
crate::getter! { followers_count::followersCount -> u64 }
#[cfg(feature = "activitypub-counters")]
crate::getter! { statuses_count::statusesCount -> u64 }
crate::getter! { discoverable -> bool }
}
#[cfg(feature = "unstructured")]
impl Endpoints for serde_json::Value {
crate::getter! { proxy_url::proxyUrl -> &str }
crate::getter! { oauth_authorization_endpoint::oauthAuthorizationEndpoint -> &str }
crate::getter! { oauth_token_endpoint::oauthTokenEndpoint -> &str }
crate::getter! { provide_client_key::provideClientKey -> &str }
crate::getter! { sign_client_key::signClientKey -> &str }
crate::getter! { shared_inbox::sharedInbox -> &str }
fn endpoints(&self) -> Node<<Self as Object>::Object> {
todo!()
}
}
#[cfg(feature = "unstructured")]
impl ActorMut for serde_json::Value {
type PublicKey = serde_json::Value;
type Endpoints = serde_json::Value;
crate::setter! { actor_type -> type ActorType }
crate::setter! { preferred_username::preferredUsername -> &str }
@ -171,33 +77,10 @@ impl ActorMut for serde_json::Value {
crate::setter! { liked -> node Self::Collection }
crate::setter! { streams -> node Self::Collection }
crate::setter! { public_key::publicKey -> node Self::PublicKey }
crate::setter! { endpoints -> node Self::Endpoints }
crate::setter! { discoverable -> bool }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::setter! { moved_to::movedTo -> node Self::Actor }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::setter! { manually_approves_followers::manuallyApprovedFollowers -> bool }
#[cfg(feature = "activitypub-fe")]
crate::setter! { following_me::followingMe -> bool }
#[cfg(feature = "activitypub-fe")]
crate::setter! { followed_by_me::followedByMe -> bool }
#[cfg(feature = "activitypub-counters")]
crate::setter! { following_count::followingCount -> u64 }
#[cfg(feature = "activitypub-counters")]
crate::setter! { followers_count::followersCount -> u64 }
#[cfg(feature = "activitypub-counters")]
crate::setter! { statuses_count::statusesCount -> u64 }
}
#[cfg(feature = "unstructured")]
impl EndpointsMut for serde_json::Value {
crate::setter! { proxy_url::proxyUrl -> &str }
crate::setter! { oauth_authorization_endpoint::oauthAuthorizationEndpoint -> &str }
crate::setter! { oauth_token_endpoint::oauthTokenEndpoint -> &str }
crate::setter! { provide_client_key::provideClientKey -> &str }
crate::setter! { sign_client_key::signClientKey -> &str }
crate::setter! { shared_inbox::sharedInbox -> &str }
fn set_endpoints(mut self, _val: Node<<Self as Object>::Object>) -> Self {
self.as_object_mut().unwrap().insert("endpoints".to_string(), serde_json::Value::Object(serde_json::Map::default()));
self
}
}

View file

@ -7,12 +7,12 @@ pub mod place;
pub mod profile;
pub mod relationship;
use crate::{Base, BaseMut, Node};
use crate::{Base, BaseMut, Link, Node};
use actor::ActorType;
use document::DocumentType;
use actor::{Actor, ActorType};
use document::{Document, DocumentType};
use activity::ActivityType;
use collection::CollectionType;
use collection::{Collection, CollectionType};
crate::strenum! {
pub enum ObjectType {
@ -33,12 +33,11 @@ crate::strenum! {
}
pub trait Object : Base {
type Link : crate::Link;
type Actor : crate::Actor;
type Link : Link;
type Actor : Actor;
type Object : Object;
type Collection : crate::Collection;
type Document : crate::Document;
type Activity : crate::Activity;
type Collection : Collection;
type Document : Document;
fn object_type(&self) -> Option<ObjectType> { None }
fn attachment(&self) -> Node<Self::Object> { Node::Empty }
@ -55,13 +54,11 @@ pub trait Object : Base {
fn location(&self) -> Node<Self::Object> { Node::Empty }
fn preview(&self) -> Node<Self::Object> { Node::Empty } // also in link
fn published(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
fn updated(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
fn replies(&self) -> Node<Self::Collection> { Node::Empty }
fn likes(&self) -> Node<Self::Collection> { Node::Empty }
fn shares(&self) -> Node<Self::Collection> { Node::Empty }
fn start_time(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
fn summary(&self) -> Option<&str> { None }
fn tag(&self) -> Node<Self::Object> { Node::Empty }
fn updated(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
fn url(&self) -> Node<Self::Link> { Node::Empty }
fn to(&self) -> Node<Self::Link> { Node::Empty }
fn bto(&self) -> Node<Self::Link> { Node::Empty }
@ -69,25 +66,14 @@ pub trait Object : Base {
fn bcc(&self) -> Node<Self::Link> { Node::Empty }
fn media_type(&self) -> Option<&str> { None } // also in link
fn duration(&self) -> Option<&str> { None } // TODO how to parse xsd:duration ?
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn sensitive(&self) -> Option<bool> { None }
#[cfg(feature = "activitypub-fe")]
fn liked_by_me(&self) -> Option<bool> { None }
fn as_activity(&self) -> Option<&Self::Activity> { None }
fn as_actor(&self) -> Option<&Self::Actor> { None }
fn as_collection(&self) -> Option<&Self::Collection> { None }
fn as_document(&self) -> Option<&Self::Document> { None }
}
pub trait ObjectMut : BaseMut {
type Link : crate::Link;
type Actor : crate::Actor;
type Link : Link;
type Actor : Actor;
type Object : Object;
type Collection : crate::Collection;
type Document : crate::Document;
type Collection : Collection;
type Document : Document;
fn set_object_type(self, val: Option<ObjectType>) -> Self;
fn set_attachment(self, val: Node<Self::Object>) -> Self;
@ -104,13 +90,11 @@ pub trait ObjectMut : BaseMut {
fn set_location(self, val: Node<Self::Object>) -> Self;
fn set_preview(self, val: Node<Self::Object>) -> Self; // also in link
fn set_published(self, val: Option<chrono::DateTime<chrono::Utc>>) -> Self;
fn set_updated(self, val: Option<chrono::DateTime<chrono::Utc>>) -> Self;
fn set_replies(self, val: Node<Self::Collection>) -> Self;
fn set_likes(self, val: Node<Self::Collection>) -> Self;
fn set_shares(self, val: Node<Self::Collection>) -> Self;
fn set_start_time(self, val: Option<chrono::DateTime<chrono::Utc>>) -> Self;
fn set_summary(self, val: Option<&str>) -> Self;
fn set_tag(self, val: Node<Self::Object>) -> Self;
fn set_updated(self, val: Option<chrono::DateTime<chrono::Utc>>) -> Self;
fn set_url(self, val: Node<Self::Link>) -> Self;
fn set_to(self, val: Node<Self::Link>) -> Self;
fn set_bto(self, val: Node<Self::Link>) -> Self;
@ -118,12 +102,6 @@ pub trait ObjectMut : BaseMut {
fn set_bcc(self, val: Node<Self::Link>) -> Self;
fn set_media_type(self, val: Option<&str>) -> Self; // also in link
fn set_duration(self, val: Option<&str>) -> Self; // TODO how to parse xsd:duration ?
#[cfg(feature = "activitypub-miscellaneous-terms")]
fn set_sensitive(self, val: Option<bool>) -> Self;
#[cfg(feature = "activitypub-fe")]
fn set_liked_by_me(self, val: Option<bool>) -> Self;
}
#[cfg(feature = "unstructured")]
@ -133,7 +111,6 @@ impl Object for serde_json::Value {
type Object = serde_json::Value;
type Document = serde_json::Value;
type Collection = serde_json::Value;
type Activity = serde_json::Value;
crate::getter! { object_type -> type ObjectType }
crate::getter! { attachment -> node <Self as Object>::Object }
@ -149,27 +126,19 @@ impl Object for serde_json::Value {
crate::getter! { location -> node <Self as Object>::Object }
crate::getter! { preview -> node <Self as Object>::Object }
crate::getter! { published -> chrono::DateTime<chrono::Utc> }
crate::getter! { updated -> chrono::DateTime<chrono::Utc> }
crate::getter! { replies -> node Self::Collection }
crate::getter! { likes -> node Self::Collection }
crate::getter! { shares -> node Self::Collection }
crate::getter! { start_time::startTime -> chrono::DateTime<chrono::Utc> }
crate::getter! { summary -> &str }
crate::getter! { tag -> node <Self as Object>::Object }
crate::getter! { updated -> chrono::DateTime<chrono::Utc> }
crate::getter! { to -> node Self::Link }
crate::getter! { bto -> node Self::Link }
crate::getter! { cc -> node Self::Link }
crate::getter! { bcc -> node Self::Link }
crate::getter! { media_type::mediaType -> &str }
crate::getter! { media_type -> &str }
crate::getter! { duration -> &str }
crate::getter! { url -> node Self::Link }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::getter! { sensitive -> bool }
#[cfg(feature = "activitypub-fe")]
crate::getter! { liked_by_me::likedByMe -> bool }
// TODO Mastodon doesn't use a "context" field on the object but makes up a new one!!
fn context(&self) -> Node<<Self as Object>::Object> {
match self.get("context") {
@ -180,34 +149,6 @@ impl Object for serde_json::Value {
}
}
}
fn as_activity(&self) -> Option<&Self::Activity> {
match self.object_type() {
Some(ObjectType::Activity(_)) => Some(self),
_ => None,
}
}
fn as_actor(&self) -> Option<&Self::Actor> {
match self.object_type() {
Some(ObjectType::Actor(_)) => Some(self),
_ => None,
}
}
fn as_collection(&self) -> Option<&Self::Collection> {
match self.object_type() {
Some(ObjectType::Collection(_)) => Some(self),
_ => None,
}
}
fn as_document(&self) -> Option<&Self::Document> {
match self.object_type() {
Some(ObjectType::Document(_)) => Some(self),
_ => None,
}
}
}
#[cfg(feature = "unstructured")]
@ -232,25 +173,30 @@ impl ObjectMut for serde_json::Value {
crate::setter! { location -> node <Self as Object>::Object }
crate::setter! { preview -> node <Self as Object>::Object }
crate::setter! { published -> chrono::DateTime<chrono::Utc> }
crate::setter! { updated -> chrono::DateTime<chrono::Utc> }
crate::setter! { replies -> node Self::Collection }
crate::setter! { likes -> node Self::Collection }
crate::setter! { shares -> node Self::Collection }
crate::setter! { start_time::startTime -> chrono::DateTime<chrono::Utc> }
crate::setter! { summary -> &str }
crate::setter! { tag -> node <Self as Object>::Object }
crate::setter! { updated -> chrono::DateTime<chrono::Utc> }
crate::setter! { to -> node Self::Link }
crate::setter! { bto -> node Self::Link}
crate::setter! { cc -> node Self::Link }
crate::setter! { bcc -> node Self::Link }
crate::setter! { media_type::mediaType -> &str }
crate::setter! { media_type -> &str }
crate::setter! { duration -> &str }
crate::setter! { url -> node Self::Link }
crate::setter! { context -> node <Self as Object>::Object }
#[cfg(feature = "activitypub-miscellaneous-terms")]
crate::setter! { sensitive -> bool }
// TODO Mastodon doesn't use a "context" field on the object but makes up a new one!!
fn set_context(mut self, ctx: Node<<Self as Object>::Object>) -> Self {
if let Some(conversation) = ctx.id() {
crate::macros::set_maybe_value(
&mut self, "conversation", Some(serde_json::Value::String(conversation)),
);
}
crate::macros::set_maybe_node(
&mut self, "context", ctx
);
self
}
#[cfg(feature = "activitypub-fe")]
crate::setter! { liked_by_me::likedByMe -> bool }
}

View file

@ -1,23 +0,0 @@
[package]
name = "mdhtml"
version = "0.1.0"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "Parse and display a markdown-like HTML subset"
license = "MIT"
keywords = ["html", "markdown", "parser"]
repository = "https://git.alemi.dev/upub.git"
readme = "README.md"
[lib]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
html5ever = "0.27"
tracing = "0.1"
comrak = { version = "0.23", optional = true }
[features]
default = ["markdown"]
markdown = ["dep:comrak"]

View file

@ -1,89 +0,0 @@
use html5ever::tendril::*;
use html5ever::tokenizer::{BufferQueue, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer};
use comrak::{markdown_to_html, Options};
/// In our case, our sink only contains a tokens vector
#[derive(Debug, Clone, Default)]
struct Sink(String);
impl TokenSink for Sink {
type Handle = ();
/// Each processed token will be handled by this method
fn process_token(&mut self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
match token {
Token::TagToken(tag) => {
if !matches!(
tag.name.as_ref(),
"h1" | "h2" | "h3"
| "hr" | "br" | "p" | "b" | "i"
| "blockquote" | "pre" | "code"
| "ul" | "ol" | "li"
| "img" | "a"
) { return TokenSinkResult::Continue } // skip this tag
self.0.push('<');
if !tag.self_closing && matches!(tag.kind, TagKind::EndTag) {
self.0.push('/');
}
self.0.push_str(tag.name.as_ref());
match tag.name.as_ref() {
"img" => for attr in tag.attrs {
match attr.name.local.as_ref() {
"src" => self.0.push_str(&format!(" src=\"{}\"", attr.value.as_ref())),
"title" => self.0.push_str(&format!(" title=\"{}\"", attr.value.as_ref())),
"alt" => self.0.push_str(&format!(" alt=\"{}\"", attr.value.as_ref())),
_ => {},
}
},
"a" => {
for attr in tag.attrs {
match attr.name.local.as_ref() {
"href" => self.0.push_str(&format!(" href=\"{}\"", attr.value.as_ref())),
"title" => self.0.push_str(&format!(" title=\"{}\"", attr.value.as_ref())),
_ => {},
}
}
self.0.push_str(" rel=\"nofollow noreferrer\" target=\"_blank\"");
},
_ => {},
}
if tag.self_closing {
self.0.push('/');
}
self.0.push('>');
},
Token::CharacterTokens(txt) => self.0.push_str(txt.as_ref()),
Token::CommentToken(_) => {},
Token::DoctypeToken(_) => {},
Token::NullCharacterToken => {},
Token::EOFToken => {},
Token::ParseError(e) => tracing::error!("error parsing html: {e}"),
}
TokenSinkResult::Continue
}
}
pub fn safe_markdown(text: &str) -> String {
safe_html(&markdown_to_html(text, &Options::default()))
}
pub fn safe_html(text: &str) -> String {
let mut input = BufferQueue::default();
input.push_back(text.to_tendril().try_reinterpret().unwrap());
let sink = Sink::default();
let mut tok = Tokenizer::new(sink, Default::default());
let _ = tok.feed(&mut input);
if !input.is_empty() {
tracing::warn!("buffer input not empty after processing html");
}
tok.end();
tok.sink.0
}

View file

@ -1,39 +0,0 @@
use sea_orm::{EntityTrait, IntoActiveModel};
use crate::server::fetcher::Fetchable;
pub async fn fetch(ctx: crate::server::Context, uri: String, save: bool) -> crate::Result<()> {
use apb::Base;
let mut node = apb::Node::link(uri.to_string());
node.fetch(&ctx).await?;
let obj = node.get().expect("node still empty after fetch?");
if save {
match obj.base_type() {
Some(apb::BaseType::Object(apb::ObjectType::Actor(_))) => {
crate::model::user::Entity::insert(
crate::model::user::Model::new(obj).unwrap().into_active_model()
).exec(ctx.db()).await.unwrap();
},
Some(apb::BaseType::Object(apb::ObjectType::Activity(_))) => {
crate::model::activity::Entity::insert(
crate::model::activity::Model::new(obj).unwrap().into_active_model()
).exec(ctx.db()).await.unwrap();
},
Some(apb::BaseType::Object(apb::ObjectType::Note)) => {
crate::model::object::Entity::insert(
crate::model::object::Model::new(obj).unwrap().into_active_model()
).exec(ctx.db()).await.unwrap();
},
Some(apb::BaseType::Object(t)) => tracing::warn!("not implemented: {:?}", t),
Some(apb::BaseType::Link(_)) => tracing::error!("fetched another link?"),
None => tracing::error!("no type on object"),
}
}
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
Ok(())
}

View file

@ -1,88 +0,0 @@
use sea_orm::EntityTrait;
pub async fn fix(ctx: crate::server::Context, likes: bool, shares: bool, replies: bool) -> crate::Result<()> {
use futures::TryStreamExt;
let db = ctx.db();
if likes {
tracing::info!("fixing likes...");
let mut store = std::collections::HashMap::new();
{
let mut stream = crate::model::like::Entity::find().stream(db).await?;
while let Some(like) = stream.try_next().await? {
store.insert(like.likes.clone(), store.get(&like.likes).unwrap_or(&0) + 1);
}
}
for (k, v) in store {
let m = crate::model::object::ActiveModel {
id: sea_orm::Set(k.clone()),
likes: sea_orm::Set(v),
..Default::default()
};
if let Err(e) = crate::model::object::Entity::update(m)
.exec(db)
.await
{
tracing::warn!("record not updated ({k}): {e}");
}
}
}
if shares {
tracing::info!("fixing shares...");
let mut store = std::collections::HashMap::new();
{
let mut stream = crate::model::share::Entity::find().stream(db).await?;
while let Some(share) = stream.try_next().await? {
store.insert(share.shares.clone(), store.get(&share.shares).unwrap_or(&0) + 1);
}
}
for (k, v) in store {
let m = crate::model::object::ActiveModel {
id: sea_orm::Set(k.clone()),
shares: sea_orm::Set(v),
..Default::default()
};
if let Err(e) = crate::model::object::Entity::update(m)
.exec(db)
.await
{
tracing::warn!("record not updated ({k}): {e}");
}
}
}
if replies {
tracing::info!("fixing replies...");
let mut store = std::collections::HashMap::new();
{
let mut stream = crate::model::object::Entity::find().stream(db).await?;
while let Some(object) = stream.try_next().await? {
if let Some(reply) = object.in_reply_to {
let before = store.get(&reply).unwrap_or(&0);
store.insert(reply, before + 1);
}
}
}
for (k, v) in store {
let m = crate::model::object::ActiveModel {
id: sea_orm::Set(k.clone()),
comments: sea_orm::Set(v),
..Default::default()
};
if let Err(e) = crate::model::object::Entity::update(m)
.exec(db)
.await
{
tracing::warn!("record not updated ({k}): {e}");
}
}
}
tracing::info!("done running fix tasks");
Ok(())
}

View file

@ -1,119 +0,0 @@
mod fix;
pub use fix::*;
mod fetch;
pub use fetch::*;
mod faker;
pub use faker::*;
mod relay;
pub use relay::*;
mod register;
pub use register::*;
mod update;
pub use update::*;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum CliCommand {
/// generate fake user, note and activity
Faker{
/// how many fake statuses to insert for root user
count: u64,
},
/// fetch a single AP object
Fetch {
/// object id, or uri, to fetch
uri: String,
#[arg(long, default_value_t = false)]
/// store fetched object in local db
save: bool,
},
/// follow a remote relay
Relay {
/// actor url, same as with pleroma
actor: String,
#[arg(long, default_value_t = false)]
/// instead of sending a follow request, send an accept
accept: bool
},
/// run db maintenance tasks
Fix {
#[arg(long, default_value_t = false)]
/// fix likes counts for posts
likes: bool,
#[arg(long, default_value_t = false)]
/// fix shares counts for posts
shares: bool,
#[arg(long, default_value_t = false)]
/// fix replies counts for posts
replies: bool,
},
/// update remote users
Update {
#[arg(long, short, default_value_t = 7)]
/// number of days after which users should get updated
days: i64,
},
/// register a new local user
Register {
/// username for new user, must be unique locally and cannot be changed
username: String,
/// password for new user
// TODO get this with getpass rather than argv!!!!
password: String,
/// display name for new user
#[arg(long = "name")]
display_name: Option<String>,
/// summary text for new user
#[arg(long = "summary")]
summary: Option<String>,
/// url for avatar image of new user
#[arg(long = "avatar")]
avatar_url: Option<String>,
/// url for banner image of new user
#[arg(long = "banner")]
banner_url: Option<String>,
}
}
pub async fn run(
command: CliCommand,
db: sea_orm::DatabaseConnection,
domain: String,
config: crate::config::Config,
) -> crate::Result<()> {
let ctx = crate::server::Context::new(
db, domain, config,
).await?;
match command {
CliCommand::Faker { count } =>
Ok(faker(ctx, count).await?),
CliCommand::Fetch { uri, save } =>
Ok(fetch(ctx, uri, save).await?),
CliCommand::Relay { actor, accept } =>
Ok(relay(ctx, actor, accept).await?),
CliCommand::Fix { likes, shares, replies } =>
Ok(fix(ctx, likes, shares, replies).await?),
CliCommand::Update { days } =>
Ok(update_users(ctx, days).await?),
CliCommand::Register { username, password, display_name, summary, avatar_url, banner_url } =>
Ok(register(ctx, username, password, display_name, summary, avatar_url, banner_url).await?),
}
}

View file

@ -1,24 +0,0 @@
use crate::server::admin::Administrable;
pub async fn register(
ctx: crate::server::Context,
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()> {
ctx.register_user(
username.clone(),
password,
display_name,
summary,
avatar_url,
banner_url,
).await?;
tracing::info!("registered new user: {username}");
Ok(())
}

View file

@ -1,38 +0,0 @@
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder};
pub async fn relay(ctx: crate::server::Context, actor: String, accept: bool) -> crate::Result<()> {
let aid = ctx.aid(uuid::Uuid::new_v4().to_string());
let mut activity_model = crate::model::activity::Model {
id: aid.clone(),
activity_type: apb::ActivityType::Follow,
actor: ctx.base(),
object: Some(actor.clone()),
target: None,
published: chrono::Utc::now(),
to: crate::model::Audience(vec![actor.clone()]),
bto: crate::model::Audience::default(),
cc: crate::model::Audience(vec![apb::target::PUBLIC.to_string()]),
bcc: crate::model::Audience::default(),
};
if accept {
let follow_req = crate::model::activity::Entity::find()
.filter(crate::model::activity::Column::ActivityType.eq("Follow"))
.filter(crate::model::activity::Column::Actor.eq(&actor))
.filter(crate::model::activity::Column::Object.eq(ctx.base()))
.order_by_desc(crate::model::activity::Column::Published)
.one(ctx.db())
.await?
.expect("no follow request to accept");
activity_model.activity_type = apb::ActivityType::Accept(apb::AcceptType::Accept);
activity_model.object = Some(follow_req.id);
};
crate::model::activity::Entity::insert(activity_model.into_active_model())
.exec(ctx.db()).await?;
ctx.dispatch(&ctx.base(), vec![actor, apb::target::PUBLIC.to_string()], &aid, None).await?;
Ok(())
}

View file

@ -1,38 +0,0 @@
use futures::TryStreamExt;
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter};
use crate::server::fetcher::Fetcher;
pub async fn update_users(ctx: crate::server::Context, days: i64) -> crate::Result<()> {
let mut count = 0;
let mut insertions = Vec::new();
{
let mut stream = crate::model::user::Entity::find()
.filter(crate::model::user::Column::Updated.lt(chrono::Utc::now() - chrono::Duration::days(days)))
.stream(ctx.db())
.await?;
while let Some(user) = stream.try_next().await? {
if ctx.is_local(&user.id) { continue }
match ctx.pull_user(&user.id).await {
Err(e) => tracing::warn!("could not update user {}: {e}", user.id),
Ok(u) => {
insertions.push(u);
count += 1;
},
}
}
}
for u in insertions {
tracing::info!("updating user {}", u.id);
crate::model::user::Entity::delete_by_id(&u.id).exec(ctx.db()).await?;
crate::model::user::Entity::insert(u.into_active_model()).exec(ctx.db()).await?;
}
tracing::info!("updated {count} users");
Ok(())
}

View file

@ -1,82 +0,0 @@
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct Config {
#[serde(default)]
pub instance: InstanceConfig,
#[serde(default)]
pub datasource: DatasourceConfig,
#[serde(default)]
pub security: SecurityConfig,
// TODO should i move app keys here?
}
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct InstanceConfig {
#[serde_inline_default("μpub".into())]
pub name: String,
#[serde_inline_default("micro social network, federated".into())]
pub description: String,
#[serde_inline_default("upub.social".into())]
pub domain: String,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub frontend: Option<String>,
}
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct DatasourceConfig {
#[serde_inline_default("sqlite://./upub.db".into())]
pub connection_string: String,
#[serde_inline_default(4)]
pub max_connections: u32,
#[serde_inline_default(1)]
pub min_connections: u32,
#[serde_inline_default(300u64)]
pub connect_timeout_seconds: u64,
#[serde_inline_default(300u64)]
pub acquire_timeout_seconds: u64,
#[serde_inline_default(1u64)]
pub slow_query_warn_seconds: u64,
#[serde_inline_default(true)]
pub slow_query_warn_enable: bool,
}
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct SecurityConfig {
#[serde(default)]
pub allow_registration: bool,
}
impl Config {
pub fn load(path: Option<std::path::PathBuf>) -> Self {
let Some(cfg_path) = path else { return Config::default() };
match std::fs::read_to_string(cfg_path) {
Ok(x) => match toml::from_str(&x) {
Ok(cfg) => return cfg,
Err(e) => tracing::error!("failed parsing config file: {e}"),
},
Err(e) => tracing::error!("failed reading config file: {e}"),
}
Config::default()
}
}

View file

@ -1,37 +1,22 @@
use axum::{http::StatusCode, response::Redirect};
#[derive(Debug, thiserror::Error)]
pub enum UpubError {
#[error("database error: {0:?}")]
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("{0}")]
#[error("api returned {0}")]
Status(axum::http::StatusCode),
#[error("missing field: {0}")]
Field(#[from] crate::model::FieldError),
#[error("openssl error: {0:?}")]
#[error("openssl error: {0}")]
OpenSSL(#[from] openssl::error::ErrorStack),
#[error("invalid UTF8 in key: {0:?}")]
#[error("invalid UTF8 in key: {0}")]
OpenSSLParse(#[from] std::str::Utf8Error),
#[error("fetch error: {0:?}")]
#[error("fetch error: {0}")]
Reqwest(#[from] reqwest::Error),
// TODO this is quite ugly because its basically a reqwest::Error but with extra string... buuut
// helps with debugging!
#[error("fetch error: {0:?} -- server responded with {1}")]
FetchError(reqwest::Error, String),
#[error("invalid base64 string: {0:?}")]
Base64(#[from] base64::DecodeError),
// TODO this isn't really an error but i need to redirect from some routes so this allows me to
// keep the type hints on the return type, still what the hell!!!!
#[error("redirecting to {0}")]
Redirect(String),
}
impl UpubError {
@ -51,10 +36,6 @@ impl UpubError {
Self::Status(axum::http::StatusCode::FORBIDDEN)
}
pub fn unauthorized() -> Self {
Self::Status(axum::http::StatusCode::UNAUTHORIZED)
}
pub fn not_modified() -> Self {
Self::Status(axum::http::StatusCode::NOT_MODIFIED)
}
@ -74,19 +55,10 @@ impl From<axum::http::StatusCode> for UpubError {
impl axum::response::IntoResponse for UpubError {
fn into_response(self) -> axum::response::Response {
let descr = self.to_string();
match self {
UpubError::Redirect(to) => Redirect::to(&to).into_response(),
UpubError::Status(status) => (status, descr).into_response(),
UpubError::Database(_) => (StatusCode::SERVICE_UNAVAILABLE, descr).into_response(),
UpubError::Reqwest(x) | UpubError::FetchError(x, _) =>
(
x.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
format!("failed fetching '{}': {descr}", x.url().map(|x| x.to_string()).unwrap_or_default())
).into_response(),
UpubError::Field(_) => (axum::http::StatusCode::BAD_REQUEST, descr).into_response(),
_ => (StatusCode::INTERNAL_SERVER_ERROR, descr).into_response(),
}
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
self.to_string()
).into_response()
}
}

View file

@ -1,47 +1,37 @@
pub mod server; // TODO there are some methods that i dont use yet, make it public so that ra shuts up
mod model;
mod routes;
pub mod server;
pub mod model;
pub mod routes;
mod errors;
mod config;
#[cfg(feature = "cli")]
mod cli;
pub mod tools;
pub mod errors;
#[cfg(feature = "migrations")]
mod migrations;
#[cfg(feature = "migrations")]
use sea_orm_migration::MigratorTrait;
use std::path::PathBuf;
use config::Config;
use clap::{Parser, Subcommand};
use sea_orm::{ConnectOptions, Database};
use sea_orm::{ConnectOptions, Database, EntityTrait, IntoActiveModel};
pub use errors::UpubResult as Result;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
/// all names were taken
struct Args {
struct CliArgs {
#[clap(subcommand)]
/// command to run
command: Mode,
command: CliCommand,
/// path to config file, leave empty to not use any
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(short = 'd', long = "db", default_value = "sqlite://./upub.db")]
/// database connection uri
database: String,
#[arg(long = "db")]
/// database connection uri, overrides config value
database: Option<String>,
#[arg(long)]
/// instance base domain, for AP ids, overrides config value
domain: Option<String>,
#[arg(short = 'D', long, default_value = "http://localhost:3000")]
/// instance base domain, for AP ids
domain: String,
#[arg(long, default_value_t=false)]
/// run with debug level tracing
@ -49,73 +39,65 @@ struct Args {
}
#[derive(Clone, Subcommand)]
enum Mode {
enum CliCommand {
/// run fediverse server
Serve,
/// print current or default configuration
Config,
Serve ,
#[cfg(feature = "migrations")]
/// apply database migrations
Migrate,
#[cfg(feature = "cli")]
/// run maintenance CLI tasks
Cli {
#[clap(subcommand)]
/// task to run
command: cli::CliCommand,
#[cfg(feature = "faker")]
/// generate fake user, note and activity
Faker{
/// how many fake statuses to insert for root user
count: u64,
},
/// fetch a single AP object
Fetch {
/// object id, or uri, to fetch
uri: String,
#[arg(long, default_value_t = false)]
/// store fetched object in local db
save: bool,
},
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let args = CliArgs::parse();
tracing_subscriber::fmt()
.compact()
.with_max_level(if args.debug { tracing::Level::DEBUG } else { tracing::Level::INFO })
.init();
let config = Config::load(args.config);
let database = args.database.unwrap_or(config.datasource.connection_string.clone());
let domain = args.domain.unwrap_or(config.instance.domain.clone());
// TODO can i do connectoptions.into() or .connect() and skip these ugly bindings?
let mut opts = ConnectOptions::new(&database);
let mut opts = ConnectOptions::new(&args.database);
opts
.sqlx_logging_level(tracing::log::LevelFilter::Debug)
.max_connections(config.datasource.max_connections)
.min_connections(config.datasource.min_connections)
.acquire_timeout(std::time::Duration::from_secs(config.datasource.acquire_timeout_seconds))
.connect_timeout(std::time::Duration::from_secs(config.datasource.connect_timeout_seconds))
.sqlx_slow_statements_logging_settings(
if config.datasource.slow_query_warn_enable { tracing::log::LevelFilter::Warn } else { tracing::log::LevelFilter::Off },
std::time::Duration::from_secs(config.datasource.slow_query_warn_seconds)
);
.sqlx_logging_level(tracing::log::LevelFilter::Debug);
let db = Database::connect(opts)
.await.expect("error connecting to db");
match args.command {
#[cfg(feature = "migrations")]
Mode::Migrate =>
migrations::Migrator::up(&db, None)
CliCommand::Migrate => migrations::Migrator::up(&db, None)
.await.expect("error applying migrations"),
#[cfg(feature = "cli")]
Mode::Cli { command } =>
cli::run(command, db, domain, config)
.await.expect("failed running cli task"),
#[cfg(feature = "faker")]
CliCommand::Faker { count } => model::faker::faker(&db, args.domain, count)
.await.expect("error creating fake entities"),
Mode::Config => println!("{}", toml::to_string_pretty(&config).expect("failed serializing config")),
CliCommand::Fetch { uri, save } => fetch(&db, &uri, save)
.await.expect("error fetching object"),
Mode::Serve => {
let ctx = server::Context::new(db, domain, config)
CliCommand::Serve => {
let ctx = server::Context::new(db, args.domain)
.await.expect("failed creating server context");
use routes::activitypub::ActivityPubRouter;
@ -124,8 +106,6 @@ async fn main() {
let router = axum::Router::new()
.ap_routes()
.mastodon_routes() // no-op if mastodon feature is disabled
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(ctx);
// run our app with hyper, listening locally on port 3000
@ -138,3 +118,42 @@ async fn main() {
},
}
}
async fn fetch(db: &sea_orm::DatabaseConnection, uri: &str, save: bool) -> reqwest::Result<()> {
use apb::{Base, Object};
let mut node = apb::Node::from(uri);
tracing::info!("fetching object");
node.fetch().await?;
tracing::info!("fetched node");
let obj = node.get().expect("node still empty after fetch?");
tracing::info!("fetched object:{}, name:{}", obj.id().unwrap_or(""), obj.name().unwrap_or(""));
if save {
match obj.base_type() {
Some(apb::BaseType::Object(apb::ObjectType::Actor(_))) => {
model::user::Entity::insert(
model::user::Model::new(obj).unwrap().into_active_model()
).exec(db).await.unwrap();
},
Some(apb::BaseType::Object(apb::ObjectType::Activity(_))) => {
model::activity::Entity::insert(
model::activity::Model::new(obj).unwrap().into_active_model()
).exec(db).await.unwrap();
},
Some(apb::BaseType::Object(apb::ObjectType::Note)) => {
model::object::Entity::insert(
model::object::Model::new(obj).unwrap().into_active_model()
).exec(db).await.unwrap();
},
Some(apb::BaseType::Object(t)) => tracing::warn!("not implemented: {:?}", t),
Some(apb::BaseType::Link(_)) => tracing::error!("fetched another link?"),
None => tracing::error!("no type on object"),
}
}
Ok(())
}

View file

@ -18,11 +18,11 @@ impl MigrationTrait for Migration {
)
.col(ColumnDef::new(Users::ActorType).string().not_null())
.col(ColumnDef::new(Users::Domain).string().not_null())
.col(ColumnDef::new(Users::Name).string().null())
.col(ColumnDef::new(Users::Name).string().not_null())
.col(ColumnDef::new(Users::Summary).string().null())
.col(ColumnDef::new(Users::Image).string().null())
.col(ColumnDef::new(Users::Icon).string().null())
.col(ColumnDef::new(Users::PreferredUsername).string().not_null())
.col(ColumnDef::new(Users::PreferredUsername).string().null())
.col(ColumnDef::new(Users::Inbox).string().null())
.col(ColumnDef::new(Users::SharedInbox).string().null())
.col(ColumnDef::new(Users::Outbox).string().null())
@ -30,7 +30,6 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(Users::Followers).string().null())
.col(ColumnDef::new(Users::FollowingCount).integer().not_null().default(0))
.col(ColumnDef::new(Users::FollowersCount).integer().not_null().default(0))
// .col(ColumnDef::new(Users::StatusesCount).integer().not_null().default(0))
.col(ColumnDef::new(Users::PublicKey).string().not_null())
.col(ColumnDef::new(Users::PrivateKey).string().null())
.col(ColumnDef::new(Users::Created).date_time().not_null())
@ -126,7 +125,6 @@ enum Users {
FollowingCount,
Followers,
FollowersCount,
// StatusesCount,
PublicKey,
PrivateKey,
Created,

View file

@ -19,7 +19,7 @@ impl MigrationTrait for Migration {
)
.col(ColumnDef::new(Addressing::Actor).string().not_null())
.col(ColumnDef::new(Addressing::Server).string().not_null())
.col(ColumnDef::new(Addressing::Activity).string().null())
.col(ColumnDef::new(Addressing::Activity).string().not_null())
.col(ColumnDef::new(Addressing::Object).string().null())
.col(ColumnDef::new(Addressing::Published).date_time().not_null())
.to_owned()

View file

@ -1,72 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Users::Table)
.add_column(
ColumnDef::new(Users::StatusesCount)
.integer()
.not_null()
.default(0)
)
.to_owned()
)
.await?;
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.add_column(
ColumnDef::new(Objects::InReplyTo)
.string()
.null()
)
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Users::Table)
.drop_column(Users::StatusesCount)
.to_owned()
)
.await?;
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.drop_column(Objects::InReplyTo)
.to_owned()
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Users {
Table,
StatusesCount,
}
#[derive(DeriveIden)]
enum Objects {
Table,
InReplyTo,
}

View file

@ -1,66 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Attachments::Table)
.col(
ColumnDef::new(Attachments::Id)
.integer()
.not_null()
.auto_increment()
.primary_key()
)
.col(ColumnDef::new(Attachments::Url).string().not_null())
.col(ColumnDef::new(Attachments::Object).string().not_null())
.col(ColumnDef::new(Attachments::DocumentType).string().not_null())
.col(ColumnDef::new(Attachments::Name).string().null())
.col(ColumnDef::new(Attachments::MediaType).string().not_null())
.col(ColumnDef::new(Attachments::Created).date_time().not_null())
.to_owned()
)
.await?;
manager
.create_index(
Index::create()
.name("attachment-object-index")
.table(Attachments::Table)
.col(Attachments::Object)
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Attachments::Table).to_owned())
.await?;
manager
.drop_index(Index::drop().name("attachment-object-index").to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Attachments {
Table,
Id,
Url,
Object,
DocumentType,
Name,
MediaType,
Created,
}

View file

@ -1,45 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.add_column(
ColumnDef::new(Objects::Sensitive)
.boolean()
.not_null()
.default(false)
)
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.drop_column(Objects::Sensitive)
.to_owned()
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Objects {
Table,
Sensitive,
}

View file

@ -1,43 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Relays::Table)
.col(
ColumnDef::new(Relays::Id)
.string()
.not_null()
.primary_key()
)
.col(ColumnDef::new(Relays::Accepted).boolean().not_null().default(false))
.col(ColumnDef::new(Relays::Forwarding).boolean().not_null().default(false))
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Relays::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Relays {
Table,
Id,
Accepted,
Forwarding,
}

View file

@ -1,40 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.add_column(ColumnDef::new(Objects::Updated).date_time().null())
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.drop_column(Objects::Updated)
.to_owned()
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Objects {
Table,
Updated,
}

View file

@ -1,40 +0,0 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.add_column(ColumnDef::new(Objects::Url).string().null())
.to_owned()
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Objects::Table)
.drop_column(Objects::Url)
.to_owned()
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Objects {
Table,
Url,
}

View file

@ -9,12 +9,6 @@ mod m20240323_000002_add_simple_credentials;
mod m20240324_000001_add_addressing;
mod m20240325_000001_add_deliveries;
mod m20240325_000002_add_system_key;
mod m20240418_000001_add_statuses_and_reply_to;
mod m20240421_000001_add_attachments;
mod m20240424_000001_add_sensitive_field;
mod m20240429_000001_add_relays_table;
mod m20240502_000001_add_object_updated;
mod m20240512_000001_add_url_to_objects;
pub struct Migrator;
@ -31,12 +25,6 @@ impl MigratorTrait for Migrator {
Box::new(m20240324_000001_add_addressing::Migration),
Box::new(m20240325_000001_add_deliveries::Migration),
Box::new(m20240325_000002_add_system_key::Migration),
Box::new(m20240418_000001_add_statuses_and_reply_to::Migration),
Box::new(m20240421_000001_add_attachments::Migration),
Box::new(m20240424_000001_add_sensitive_field::Migration),
Box::new(m20240429_000001_add_relays_table::Migration),
Box::new(m20240502_000001_add_object_updated::Migration),
Box::new(m20240512_000001_add_url_to_objects::Migration),
]
}
}

View file

@ -1,8 +1,5 @@
use apb::{ActivityMut, BaseMut, ObjectMut};
use sea_orm::entity::prelude::*;
use crate::routes::activitypub::jsonld::LD;
use super::Audience;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
@ -40,20 +37,6 @@ impl Model {
bcc: activity.bcc().into(),
})
}
pub fn ap(self) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&self.id))
.set_activity_type(Some(self.activity_type))
.set_actor(apb::Node::link(self.actor))
.set_object(apb::Node::maybe_link(self.object))
.set_target(apb::Node::maybe_link(self.target))
.set_published(Some(self.published))
.set_to(apb::Node::links(self.to.0.clone()))
.set_bto(apb::Node::Empty)
.set_cc(apb::Node::links(self.cc.0.clone()))
.set_bcc(apb::Node::Empty)
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -98,13 +81,3 @@ impl Related<super::addressing::Entity> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
impl apb::target::Addressed for Model {
fn addressed(&self) -> Vec<String> {
let mut to : Vec<String> = self.to.0.clone();
to.append(&mut self.bto.0.clone());
to.append(&mut self.cc.0.clone());
to.append(&mut self.bcc.0.clone());
to
}
}

View file

@ -1,7 +1,4 @@
use apb::{ActivityMut, ObjectMut};
use sea_orm::{entity::prelude::*, sea_query::IntoCondition, Condition, FromQueryResult, Iterable, Order, QueryOrder, QuerySelect, SelectColumns};
use crate::routes::activitypub::jsonld::LD;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "addressing")]
@ -10,7 +7,7 @@ pub struct Model {
pub id: i64,
pub actor: String,
pub server: String,
pub activity: Option<String>,
pub activity: String,
pub object: Option<String>,
pub published: ChronoDateTimeUtc,
}
@ -58,117 +55,3 @@ impl Related<super::object::Entity> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
#[allow(clippy::large_enum_variant)] // tombstone is an outlier, not the norm! this is a beefy enum
#[derive(Debug, Clone)]
pub enum Event {
Tombstone,
Activity(crate::model::activity::Model),
StrayObject {
object: crate::model::object::Model,
liked: Option<String>,
},
DeepActivity {
activity: crate::model::activity::Model,
object: crate::model::object::Model,
liked: Option<String>,
}
}
impl Event {
pub fn id(&self) -> &str {
match self {
Event::Tombstone => "",
Event::Activity(x) => x.id.as_str(),
Event::StrayObject { object, liked: _ } => object.id.as_str(),
Event::DeepActivity { activity: _, liked: _, object } => object.id.as_str(),
}
}
pub fn ap(self, attachment: Option<Vec<crate::model::attachment::Model>>) -> serde_json::Value {
let attachment = match attachment {
None => apb::Node::Empty,
Some(vec) => apb::Node::array(
vec.into_iter().map(|x| x.ap()).collect()
),
};
match self {
Event::Activity(x) => x.ap(),
Event::DeepActivity { activity, object, liked } =>
activity.ap().set_object(apb::Node::object(
object.ap()
.set_attachment(attachment)
.set_liked_by_me(if liked.is_some() { Some(true) } else { None })
)),
Event::StrayObject { object, liked } => serde_json::Value::new_object()
.set_activity_type(Some(apb::ActivityType::Activity))
.set_object(apb::Node::object(
object.ap()
.set_attachment(attachment)
.set_liked_by_me(if liked.is_some() { Some(true) } else { None })
)),
Event::Tombstone => serde_json::Value::new_object()
.set_activity_type(Some(apb::ActivityType::Activity))
.set_object(apb::Node::object(
serde_json::Value::new_object()
.set_object_type(Some(apb::ObjectType::Tombstone))
)),
}
}
}
impl FromQueryResult for Event {
fn from_query_result(res: &sea_orm::QueryResult, _pre: &str) -> Result<Self, sea_orm::DbErr> {
let activity = crate::model::activity::Model::from_query_result(res, crate::model::activity::Entity.table_name()).ok();
let object = crate::model::object::Model::from_query_result(res, crate::model::object::Entity.table_name()).ok();
let liked = res.try_get(crate::model::like::Entity.table_name(), &crate::model::like::Column::Actor.to_string()).ok();
match (activity, object) {
(Some(activity), Some(object)) => Ok(Self::DeepActivity { activity, object, liked }),
(Some(activity), None) => Ok(Self::Activity(activity)),
(None, Some(object)) => Ok(Self::StrayObject { object, liked }),
(None, None) => Ok(Self::Tombstone),
}
}
}
impl Entity {
pub fn find_addressed(uid: Option<&str>) -> Select<Entity> {
let mut select = Entity::find()
.distinct()
.select_only()
.join(sea_orm::JoinType::LeftJoin, Relation::Object.def())
.join(sea_orm::JoinType::LeftJoin, Relation::Activity.def())
.filter(
// TODO ghetto double inner join because i want to filter out tombstones
Condition::any()
.add(crate::model::activity::Column::Id.is_not_null())
.add(crate::model::object::Column::Id.is_not_null())
)
.order_by(Column::Published, Order::Desc);
if let Some(uid) = uid {
let uid = uid.to_string();
select = select
.join(
sea_orm::JoinType::LeftJoin,
crate::model::object::Relation::Like.def()
.on_condition(move |_l, _r| crate::model::like::Column::Actor.eq(uid.clone()).into_condition()),
)
.select_column_as(crate::model::like::Column::Actor, format!("{}{}", crate::model::like::Entity.table_name(), crate::model::like::Column::Actor.to_string()));
}
for col in crate::model::object::Column::iter() {
select = select.select_column_as(col, format!("{}{}", crate::model::object::Entity.table_name(), col.to_string()));
}
for col in crate::model::activity::Column::iter() {
select = select.select_column_as(col, format!("{}{}", crate::model::activity::Entity.table_name(), col.to_string()));
}
select
}
}

View file

@ -1,115 +0,0 @@
use apb::{Document, DocumentMut, Link, Object, ObjectMut};
use sea_orm::{entity::prelude::*, Set};
use crate::routes::activitypub::jsonld::LD;
use super::addressing::Event;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "attachments")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub url: String,
pub object: String,
pub document_type: apb::DocumentType,
pub name: Option<String>,
pub media_type: String,
pub created: ChronoDateTimeUtc,
}
impl ActiveModel {
// TODO receive an impl, not a specific type!
// issue is that it's either an apb::Link or apb::Document, but Document doesnt inherit from link!
pub fn new(document: &serde_json::Value, object: String, media_type: Option<String>) -> Result<ActiveModel, super::FieldError> {
let media_type = media_type.unwrap_or_else(|| document.media_type().unwrap_or("link").to_string());
Ok(ActiveModel {
id: sea_orm::ActiveValue::NotSet,
object: Set(object),
url: Set(document.url().id().unwrap_or_else(|| document.href().to_string())),
document_type: Set(document.document_type().unwrap_or(apb::DocumentType::Page)),
media_type: Set(media_type),
name: Set(document.name().map(|x| x.to_string())),
created: Set(document.published().unwrap_or(chrono::Utc::now())),
})
}
}
impl Model {
pub fn ap(self) -> serde_json::Value {
serde_json::Value::new_object()
.set_url(apb::Node::link(self.url))
.set_document_type(Some(self.document_type))
.set_media_type(Some(&self.media_type))
.set_name(self.name.as_deref())
.set_published(Some(self.created))
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::object::Entity",
from = "Column::Object",
to = "super::object::Column::Id"
)]
Object,
}
impl Related<super::object::Entity> for Entity {
fn to() -> RelationDef {
Relation::Object.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
#[axum::async_trait]
pub trait BatchFillable {
async fn load_attachments_batch(&self, db: &DatabaseConnection) -> Result<std::collections::BTreeMap<String, Vec<Model>>, DbErr>;
}
#[axum::async_trait]
impl BatchFillable for &[Event] {
async fn load_attachments_batch(&self, db: &DatabaseConnection) -> Result<std::collections::BTreeMap<String, Vec<Model>>, DbErr> {
let objects : Vec<crate::model::object::Model> = self
.iter()
.filter_map(|x| match x {
Event::Tombstone => None,
Event::Activity(_) => None,
Event::StrayObject { object, liked: _ } => Some(object.clone()),
Event::DeepActivity { activity: _, liked: _, object } => Some(object.clone()),
})
.collect();
let attachments = objects.load_many(Entity, db).await?;
let mut out : std::collections::BTreeMap<String, Vec<Model>> = std::collections::BTreeMap::new();
for attach in attachments.into_iter().flatten() {
if out.contains_key(&attach.object) {
out.get_mut(&attach.object).expect("contains but get failed?").push(attach);
} else {
out.insert(attach.object.clone(), vec![attach]);
}
}
Ok(out)
}
}
#[axum::async_trait]
impl BatchFillable for Vec<Event> {
async fn load_attachments_batch(&self, db: &DatabaseConnection) -> Result<std::collections::BTreeMap<String, Vec<Model>>, DbErr> {
self.as_slice().load_attachments_batch(db).await
}
}
#[axum::async_trait]
impl BatchFillable for Event {
async fn load_attachments_batch(&self, db: &DatabaseConnection) -> Result<std::collections::BTreeMap<String, Vec<Model>>, DbErr> {
let x = vec![self.clone()]; // TODO wasteful clone and vec![] but ehhh convenient
x.load_attachments_batch(db).await
}
}

View file

@ -1,25 +1,22 @@
use crate::model::{addressing, config, credential, activity, object, user, Audience};
use crate::model::{config, credential};
use super::{activity, object, user, Audience};
use openssl::rsa::Rsa;
use sea_orm::IntoActiveModel;
pub async fn faker(ctx: crate::server::Context, count: u64) -> Result<(), sea_orm::DbErr> {
pub async fn faker(db: &sea_orm::DatabaseConnection, domain: String, count: u64) -> Result<(), sea_orm::DbErr> {
use sea_orm::{EntityTrait, Set};
let domain = ctx.domain();
let db = ctx.db();
let key = Rsa::generate(2048).unwrap();
let test_user = user::Model {
let test_user = super::user::Model {
id: format!("{domain}/users/test"),
name: Some("μpub".into()),
domain: clean_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,
following_count: 0,
followers: None,
followers_count: 0,
statuses_count: count as i64,
icon: Some("https://cdn.alemi.dev/social/circle-square.png".to_string()),
image: Some("https://cdn.alemi.dev/social/someriver-xs.jpg".to_string()),
inbox: None,
@ -55,16 +52,6 @@ pub async fn faker(ctx: crate::server::Context, count: u64) -> Result<(), sea_or
for i in (0..count).rev() {
let oid = uuid::Uuid::new_v4();
let aid = uuid::Uuid::new_v4();
addressing::Entity::insert(addressing::ActiveModel {
actor: Set(apb::target::PUBLIC.to_string()),
server: Set("www.w3.org".to_string()),
activity: Set(Some(format!("{domain}/activities/{aid}"))),
object: Set(Some(format!("{domain}/objects/{oid}"))),
published: Set(chrono::Utc::now()),
..Default::default()
}).exec(db).await?;
object::Entity::insert(object::ActiveModel {
id: Set(format!("{domain}/objects/{oid}")),
name: Set(None),
@ -72,10 +59,8 @@ pub async fn faker(ctx: crate::server::Context, count: u64) -> Result<(), sea_or
attributed_to: Set(Some(format!("{domain}/users/test"))),
summary: Set(None),
context: Set(Some(context.clone())),
in_reply_to: Set(None),
content: Set(Some(format!("[{i}] Tic(k). Quasiparticle of intensive multiplicity. Tics (or ticks) are intrinsically several components of autonomously numbering anorganic populations, propagating by contagion between segmentary divisions in the order of nature. Ticks - as nonqualitative differentially-decomposable counting marks - each designate a multitude comprehended as a singular variation in tic(k)-density."))),
published: Set(chrono::Utc::now() - std::time::Duration::from_secs(60*i)),
updated: Set(None),
comments: Set(0),
likes: Set(0),
shares: Set(0),
@ -83,8 +68,6 @@ pub async fn faker(ctx: crate::server::Context, count: u64) -> Result<(), sea_or
bto: Set(Audience::default()),
cc: Set(Audience(vec![])),
bcc: Set(Audience::default()),
url: Set(None),
sensitive: Set(false),
}).exec(db).await?;
activity::Entity::insert(activity::ActiveModel {

View file

@ -11,19 +11,6 @@ pub struct Model {
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::object::Entity",
from = "Column::Likes",
to = "super::object::Column::Id",
)]
Object
}
impl Related<super::object::Entity> for Entity {
fn to() -> RelationDef {
Relation::Object.def()
}
}
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -3,7 +3,6 @@ pub mod activity;
pub mod user;
pub mod config;
pub mod relay;
pub mod relation;
pub mod addressing;
pub mod share;
@ -11,9 +10,11 @@ pub mod like;
pub mod credential;
pub mod session;
pub mod delivery;
pub mod attachment;
pub mod application;
#[cfg(feature = "faker")]
pub mod faker;
#[derive(Debug, Clone, thiserror::Error)]
#[error("missing required field: '{0}'")]
pub struct FieldError(pub &'static str);
@ -28,14 +29,15 @@ impl From<FieldError> for axum::http::StatusCode {
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, sea_orm::FromJsonQueryResult)]
pub struct Audience(pub Vec<String>);
impl<T: apb::Base> From<apb::Node<T>> for Audience {
fn from(value: apb::Node<T>) -> Self {
use apb::{Link, Node};
impl<T : Link> From<Node<T>> for Audience {
fn from(value: Node<T>) -> Self {
Audience(
match value {
apb::Node::Empty => vec![],
apb::Node::Link(l) => vec![l.href().to_string()],
apb::Node::Object(o) => if let Some(id) = o.id() { vec![id.to_string()] } else { vec![] },
apb::Node::Array(arr) => arr.into_iter().filter_map(|l| Some(l.id()?.to_string())).collect(),
Node::Empty => vec![],
Node::Link(l) => vec![l.href().to_string()],
Node::Object(o) => if let Some(id) = o.id() { vec![id.to_string()] } else { vec![] },
Node::Array(arr) => arr.into_iter().filter_map(|l| Some(l.id()?.to_string())).collect(),
}
)
}

View file

@ -1,8 +1,5 @@
use apb::{BaseMut, Collection, CollectionMut, ObjectMut};
use sea_orm::entity::prelude::*;
use crate::routes::activitypub::jsonld::LD;
use super::Audience;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
@ -19,16 +16,11 @@ pub struct Model {
pub shares: i64,
pub comments: i64,
pub context: Option<String>,
pub in_reply_to: Option<String>,
pub cc: Audience,
pub bcc: Audience,
pub to: Audience,
pub bto: Audience,
pub url: Option<String>,
pub published: ChronoDateTimeUtc,
pub updated: Option<ChronoDateTimeUtc>,
pub sensitive: bool,
}
impl Model {
@ -41,59 +33,16 @@ impl Model {
summary: object.summary().map(|x| x.to_string()),
content: object.content().map(|x| x.to_string()),
context: object.context().id(),
in_reply_to: object.in_reply_to().id(),
published: object.published().ok_or(super::FieldError("published"))?,
updated: object.updated(),
url: object.url().id(),
comments: object.replies().get()
.map_or(0, |x| x.total_items().unwrap_or(0)) as i64,
likes: object.likes().get()
.map_or(0, |x| x.total_items().unwrap_or(0)) as i64,
shares: object.shares().get()
.map_or(0, |x| x.total_items().unwrap_or(0)) as i64,
comments: 0,
likes: 0,
shares: 0,
to: object.to().into(),
bto: object.bto().into(),
cc: object.cc().into(),
bcc: object.bcc().into(),
sensitive: object.sensitive().unwrap_or(false),
})
}
pub fn ap(self) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&self.id))
.set_object_type(Some(self.object_type))
.set_attributed_to(apb::Node::maybe_link(self.attributed_to))
.set_name(self.name.as_deref())
.set_summary(self.summary.as_deref())
.set_content(self.content.as_deref())
.set_context(apb::Node::maybe_link(self.context.clone()))
.set_in_reply_to(apb::Node::maybe_link(self.in_reply_to.clone()))
.set_published(Some(self.published))
.set_updated(self.updated)
.set_to(apb::Node::links(self.to.0.clone()))
.set_bto(apb::Node::Empty)
.set_cc(apb::Node::links(self.cc.0.clone()))
.set_bcc(apb::Node::Empty)
.set_url(apb::Node::maybe_link(self.url))
.set_sensitive(Some(self.sensitive))
.set_shares(apb::Node::object(
serde_json::Value::new_object()
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_total_items(Some(self.shares as u64))
))
.set_likes(apb::Node::object(
serde_json::Value::new_object()
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_total_items(Some(self.likes as u64))
))
.set_replies(apb::Node::object(
serde_json::Value::new_object()
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_total_items(Some(self.comments as u64))
))
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -110,15 +59,6 @@ pub enum Relation {
#[sea_orm(has_many = "super::addressing::Entity")]
Addressing,
#[sea_orm(has_many = "super::attachment::Entity")]
Attachment,
#[sea_orm(has_many = "super::like::Entity")]
Like,
#[sea_orm(has_many = "super::share::Entity")]
Share,
}
impl Related<super::activity::Entity> for Entity {
@ -139,32 +79,4 @@ impl Related<super::addressing::Entity> for Entity {
}
}
impl Related<super::attachment::Entity> for Entity {
fn to() -> RelationDef {
Relation::Attachment.def()
}
}
impl Related<super::like::Entity> for Entity {
fn to() -> RelationDef {
Relation::Like.def()
}
}
impl Related<super::share::Entity> for Entity {
fn to() -> RelationDef {
Relation::Share.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
impl apb::target::Addressed for Model {
fn addressed(&self) -> Vec<String> {
let mut to : Vec<String> = self.to.0.clone();
to.append(&mut self.bto.0.clone());
to.append(&mut self.cc.0.clone());
to.append(&mut self.bcc.0.clone());
to
}
}

View file

@ -1,16 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "relays")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: String,
pub accepted: bool,
pub forwarding: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
// TODO how to represent this User-to-User relation in sea orm??
impl ActiveModelBehavior for ActiveModel {}

View file

@ -11,19 +11,6 @@ pub struct Model {
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::object::Entity",
from = "Column::Shares",
to = "super::object::Column::Id",
)]
Object
}
impl Related<super::object::Entity> for Entity {
fn to() -> RelationDef {
Relation::Object.def()
}
}
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,8 +1,6 @@
use sea_orm::entity::prelude::*;
use apb::{Actor, ActorMut, ActorType, BaseMut, DocumentMut, Endpoints, EndpointsMut, Object, ObjectMut, PublicKey, PublicKeyMut};
use crate::routes::activitypub::jsonld::LD;
use apb::{Collection, Actor, PublicKey, ActorType};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "users")]
@ -26,7 +24,6 @@ pub struct Model {
pub following_count: i64,
pub followers_count: i64,
pub statuses_count: i64,
pub public_key: String,
pub private_key: Option<String>,
@ -42,68 +39,27 @@ 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, fallback_preferred_username) = split_user_id(&ap_id);
let (domain, preferred_username) = split_user_id(&ap_id);
Ok(Model {
id: ap_id,
domain,
preferred_username: object.preferred_username().unwrap_or(&fallback_preferred_username).to_string(),
id: ap_id, preferred_username, domain,
actor_type: object.actor_type().ok_or(super::FieldError("type"))?,
name: object.name().map(|x| x.to_string()),
summary: object.summary().map(|x| x.to_string()),
icon: object.icon().get().and_then(|x| x.url().id()),
image: object.image().get().and_then(|x| x.url().id()),
icon: object.icon().id(),
image: object.image().id(),
inbox: object.inbox().id(),
outbox: object.outbox().id(),
shared_inbox: object.endpoints().get().and_then(|x| Some(x.shared_inbox()?.to_string())),
outbox: object.inbox().id(),
shared_inbox: None, // TODO!!! parse endpoints
followers: object.followers().id(),
following: object.following().id(),
created: object.published().unwrap_or(chrono::Utc::now()),
updated: chrono::Utc::now(),
following_count: object.following_count().unwrap_or(0) as i64,
followers_count: object.followers_count().unwrap_or(0) as i64,
statuses_count: object.statuses_count().unwrap_or(0) as i64,
following_count: object.following().get().map(|f| f.total_items().unwrap_or(0)).unwrap_or(0) as i64,
followers_count: object.followers().get().map(|f| f.total_items().unwrap_or(0)).unwrap_or(0) as i64,
public_key: object.public_key().get().ok_or(super::FieldError("publicKey"))?.public_key_pem().to_string(),
private_key: None, // there's no way to transport privkey over AP json, must come from DB
})
}
pub fn ap(self) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&self.id))
.set_actor_type(Some(self.actor_type))
.set_name(self.name.as_deref())
.set_summary(self.summary.as_deref())
.set_icon(apb::Node::maybe_object(self.icon.map(|i|
serde_json::Value::new_object()
.set_document_type(Some(apb::DocumentType::Image))
.set_url(apb::Node::link(i.clone()))
)))
.set_image(apb::Node::maybe_object(self.image.map(|i|
serde_json::Value::new_object()
.set_document_type(Some(apb::DocumentType::Image))
.set_url(apb::Node::link(i.clone()))
)))
.set_published(Some(self.created))
.set_preferred_username(Some(&self.preferred_username))
.set_statuses_count(Some(self.statuses_count as u64))
.set_followers_count(Some(self.followers_count as u64))
.set_following_count(Some(self.following_count as u64))
.set_inbox(apb::Node::maybe_link(self.inbox))
.set_outbox(apb::Node::maybe_link(self.outbox))
.set_following(apb::Node::maybe_link(self.following))
.set_followers(apb::Node::maybe_link(self.followers))
.set_public_key(apb::Node::object(
serde_json::Value::new_object()
.set_id(Some(&format!("{}#main-key", self.id)))
.set_owner(Some(&self.id))
.set_public_key_pem(&self.public_key)
))
.set_endpoints(apb::Node::object(
serde_json::Value::new_object()
.set_shared_inbox(self.shared_inbox.as_deref())
))
.set_discoverable(Some(true))
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -1,34 +1,43 @@
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, QueryFilter};
use crate::{errors::UpubError, model::{self, addressing::Event, attachment::BatchFillable}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}};
use axum::{extract::{Path, State}, http::StatusCode};
use sea_orm::EntityTrait;
use crate::{model::{self, activity, object}, server::Context};
use apb::{ActivityMut, ObjectMut, BaseMut, Node};
use super::{jsonld::LD, JsonLD, TryFetch};
use super::{jsonld::LD, JsonLD};
pub async fn view(
State(ctx): State<Context>,
Path(id): Path<String>,
AuthIdentity(auth): AuthIdentity,
Query(query): Query<TryFetch>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let aid = ctx.uri("activities", id);
if auth.is_local() && query.fetch && !ctx.is_local(&aid) {
let obj = ctx.fetch_activity(&aid).await?;
if obj.id != aid {
return Err(UpubError::Redirect(obj.id));
}
}
let row = model::addressing::Entity::find_addressed(auth.my_id())
.filter(model::activity::Column::Id.eq(&aid))
.filter(auth.filter_condition())
.into_model::<Event>()
.one(ctx.db())
.await?
.ok_or_else(UpubError::not_found)?;
let mut attachments = row.load_attachments_batch(ctx.db()).await?;
let attach = attachments.remove(row.id());
Ok(JsonLD(row.ap(attach).ld_context()))
pub fn ap_activity(activity: model::activity::Model) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&activity.id))
.set_activity_type(Some(activity.activity_type))
.set_actor(Node::link(activity.actor))
.set_object(Node::maybe_link(activity.object))
.set_target(Node::maybe_link(activity.target))
.set_published(Some(activity.published))
.set_to(Node::links(activity.to.0.clone()))
.set_bto(Node::Empty)
.set_cc(Node::links(activity.cc.0.clone()))
.set_bcc(Node::Empty)
}
pub async fn view(State(ctx) : State<Context>, Path(id): Path<String>) -> Result<JsonLD<serde_json::Value>, StatusCode> {
match activity::Entity::find_by_id(ctx.aid(id))
.find_also_related(object::Entity)
.one(ctx.db())
.await
{
Ok(Some((activity, Some(object)))) => Ok(JsonLD(
ap_activity(activity)
.set_object(Node::object(super::object::ap_object(object)))
.ld_context()
)),
Ok(Some((activity, None))) => Ok(JsonLD(
ap_activity(activity).ld_context()
)),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("error querying for activity: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
},
}
}

View file

@ -1,34 +1,19 @@
use apb::{ActorMut, BaseMut, ObjectMut, PublicKeyMut};
use axum::{extract::{Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, Json};
use reqwest::Method;
use axum::{extract::State, http::StatusCode, Json};
use crate::{errors::UpubError, server::{auth::{AuthIdentity, Identity}, fetcher::Fetcher, Context}, url};
use crate::{server::Context, url};
use super::{jsonld::LD, JsonLD};
use super::jsonld::LD;
pub async fn view(
headers: HeaderMap,
State(ctx): State<Context>,
) -> crate::Result<Response> {
if let Some(accept) = headers.get("Accept") {
if let Ok(accept) = accept.to_str() {
if accept.contains("text/html") {
return Ok(Redirect::to("/web").into_response());
}
}
}
Ok(JsonLD(
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(&ctx.cfg().instance.name))
.set_summary(Some(&ctx.cfg().instance.description))
.set_inbox(apb::Node::link(url!(ctx, "/inbox")))
.set_outbox(apb::Node::link(url!(ctx, "/outbox")))
.set_name(Some("μpub"))
.set_summary(Some("micro social network, federated"))
.set_published(Some(ctx.app().created))
.set_endpoints(apb::Node::Empty)
.set_preferred_username(Some(ctx.domain()))
.set_public_key(apb::Node::object(
serde_json::Value::new_object()
.set_id(Some(&url!(ctx, "#main-key")))
@ -36,34 +21,5 @@ pub async fn view(
.set_public_key_pem(&ctx.app().public_key)
))
.ld_context()
).into_response())
}
#[derive(Debug, serde::Deserialize)]
pub struct FetchPath {
id: String,
}
pub async fn debug(
State(ctx): State<Context>,
Query(query): Query<FetchPath>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<Json<serde_json::Value>> {
// only local users can request debug fetches
if !matches!(auth, Identity::Local(_)) {
return Err(UpubError::unauthorized());
}
Ok(Json(
Context::request(
Method::GET,
&query.id,
None,
&ctx.base(),
&ctx.app().private_key,
ctx.domain(),
)
.await?
.json::<serde_json::Value>()
.await?
))
}

View file

@ -2,7 +2,7 @@ use axum::{http::StatusCode, extract::State, Json};
use rand::Rng;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{errors::UpubError, model, server::{admin::Administrable, Context}};
use crate::{model, server::Context};
#[derive(Debug, Clone, serde::Deserialize)]
@ -11,17 +11,7 @@ pub struct LoginForm {
password: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AuthSuccess {
token: String,
user: String,
expires: chrono::DateTime<chrono::Utc>,
}
pub async fn login(
State(ctx): State<Context>,
Json(login): Json<LoginForm>
) -> crate::Result<Json<AuthSuccess>> {
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()
@ -29,60 +19,30 @@ pub async fn login(
.add(model::credential::Column::Password.eq(sha256::digest(login.password)))
)
.one(ctx.db())
.await?
.await
{
Some(x) => {
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();
let expires = chrono::Utc::now() + std::time::Duration::from_secs(3600 * 6);
model::session::Entity::insert(
model::session::ActiveModel {
id: sea_orm::ActiveValue::Set(token.clone()),
actor: sea_orm::ActiveValue::Set(x.id.clone()),
expires: sea_orm::ActiveValue::Set(expires),
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(AuthSuccess {
token, expires,
user: x.id
}))
Ok(Json(serde_json::Value::String(token)))
},
None => Err(UpubError::unauthorized()),
Ok(None) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("error querying db for user credentials: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RegisterForm {
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
}
pub async fn register(
State(ctx): State<Context>,
Json(registration): Json<RegisterForm>
) -> crate::Result<Json<String>> {
if !ctx.cfg().security.allow_registration {
return Err(UpubError::forbidden());
}
ctx.register_user(
registration.username.clone(),
registration.password,
registration.display_name,
registration.summary,
registration.avatar_url,
registration.banner_url
).await?;
Ok(Json(ctx.uid(registration.username)))
}

View file

@ -1,47 +0,0 @@
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, Condition, PaginatorTrait, QueryFilter};
use crate::{model, routes::activitypub::{JsonLD, Pagination}, server::{auth::AuthIdentity, Context}, url};
pub async fn get(
State(ctx): State<Context>,
Path(id): Path<String>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
let local_context_id = url!(ctx, "/context/{id}");
let context = ctx.uri("context", id);
let count = model::addressing::Entity::find_addressed(auth.my_id())
.filter(auth.filter_condition())
.filter(model::object::Column::Context.eq(context))
.count(ctx.db())
.await?;
crate::server::builders::collection(&local_context_id, Some(count))
}
pub async fn page(
State(ctx): State<Context>,
Path(id): Path<String>,
Query(page): Query<Pagination>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
let context = if id.starts_with('+') {
id.replacen('+', "https://", 1).replace('@', "/")
} else if id.starts_with("tag:") {
id.clone()
} else {
url!(ctx, "/context/{id}") // TODO need a better way to figure out which ones are our contexts
};
crate::server::builders::paginate(
url!(ctx, "/context/{id}/page"),
Condition::all()
.add(auth.filter_condition())
.add(model::object::Column::Context.eq(context)),
ctx.db(),
page,
auth.my_id(),
)
.await
}

View file

@ -1,96 +1,46 @@
use apb::{server::Inbox, Activity, ActivityType};
use axum::{extract::{Query, State}, http::StatusCode, Json};
use sea_orm::{sea_query::IntoCondition, ColumnTrait};
use axum::{extract::{Query, State}, http::StatusCode};
use sea_orm::{ColumnTrait, Condition, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect};
use crate::{errors::UpubError, server::{auth::{AuthIdentity, Identity}, Context}, url};
use crate::{server::auth::{AuthIdentity, Identity}, errors::UpubError, model, server::Context, url};
use super::{JsonLD, Pagination};
use super::{activity::ap_activity, jsonld::LD, JsonLD, Pagination};
pub async fn get(
State(ctx): State<Context>,
) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::collection(&url!(ctx, "/inbox"), None)
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
Ok(JsonLD(ctx.ap_collection(&url!(ctx, "/inbox"), None).ld_context()))
}
pub async fn page(
State(ctx): State<Context>,
AuthIdentity(auth): AuthIdentity,
Query(page): Query<Pagination>,
) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::paginate(
url!(ctx, "/inbox/page"),
crate::model::addressing::Column::Actor.eq(apb::target::PUBLIC)
.into_condition(),
ctx.db(),
page,
auth.my_id(),
)
.await
}
macro_rules! pretty_json {
($json:ident) => {
serde_json::to_string_pretty(&$json).expect("failed serializing to string serde_json::Value")
}
}
pub async fn post(
State(ctx): State<Context>,
AuthIdentity(auth): AuthIdentity,
Json(activity): Json<serde_json::Value>
) -> crate::Result<()> {
let Identity::Remote(server) = auth else {
if activity.activity_type() == Some(ActivityType::Delete) {
// this is spammy af, ignore them!
// we basically received a delete for a user we can't fetch and verify, meaning remote
// deleted someone we never saw. technically we deleted nothing so we should return error,
// but mastodon keeps hammering us trying to delete this user, so just make mastodon happy
// and return 200 without even bothering checking this stuff
// would be cool if mastodon played nicer with the network...
return Ok(());
}
tracing::warn!("refusing unauthorized activity: {}", pretty_json!(activity));
if matches!(auth, Identity::Anonymous) {
return Err(UpubError::unauthorized());
} else {
return Err(UpubError::forbidden());
}
};
let Some(actor) = activity.actor().id() else {
return Err(UpubError::bad_request());
};
// TODO add whitelist of relays
if !server.ends_with(&Context::server(&actor)) {
return Err(UpubError::unauthorized());
}
tracing::debug!("processing federated activity: '{}'", serde_json::to_string(&activity).unwrap_or_default());
// TODO we could process Links and bare Objects maybe, but probably out of AP spec?
match activity.activity_type().ok_or_else(UpubError::bad_request)? {
ActivityType::Activity => {
tracing::warn!("skipping unprocessable base activity: {}", pretty_json!(activity));
Err(StatusCode::UNPROCESSABLE_ENTITY.into()) // won't ingest useless stuff
},
// TODO emojireacts are NOT likes, but let's process them like ones for now maybe?
ActivityType::Like | ActivityType::EmojiReact => Ok(ctx.like(server, activity).await?),
ActivityType::Create => Ok(ctx.create(server, activity).await?),
ActivityType::Follow => Ok(ctx.follow(server, activity).await?),
ActivityType::Announce => Ok(ctx.announce(server, activity).await?),
ActivityType::Accept(_) => Ok(ctx.accept(server, activity).await?),
ActivityType::Reject(_) => Ok(ctx.reject(server, activity).await?),
ActivityType::Undo => Ok(ctx.undo(server, activity).await?),
ActivityType::Delete => Ok(ctx.delete(server, activity).await?),
ActivityType::Update => Ok(ctx.update(server, activity).await?),
_x => {
tracing::info!("received unimplemented activity on inbox: {}", pretty_json!(activity));
Err(StatusCode::NOT_IMPLEMENTED.into())
},
}
) -> Result<JsonLD<serde_json::Value>, UpubError> {
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(apb::target::PUBLIC));
if let Identity::Local(user) = auth {
condition = condition
.add(model::addressing::Column::Actor.eq(user));
}
let activities = model::addressing::Entity::find()
.filter(condition)
.order_by(model::addressing::Column::Published, Order::Asc)
.find_also_related(model::activity::Entity)
.limit(limit)
.offset(offset)
.all(ctx.db())
.await?;
Ok(JsonLD(
ctx.ap_collection_page(
&url!(ctx, "/inbox/page"),
offset, limit,
activities
.into_iter()
.filter_map(|(_, a)| Some(ap_activity(a?)))
.collect::<Vec<serde_json::Value>>()
).ld_context()
))
}

View file

@ -1,9 +1,3 @@
// TODO
// move this file somewhere else
// it's not a route
// maybe under src/server/jsonld.rs ??
use apb::Object;
use axum::response::{IntoResponse, Response};
pub trait LD {
@ -15,31 +9,11 @@ pub trait LD {
impl LD for serde_json::Value {
fn ld_context(mut self) -> Self {
let o_type = self.object_type();
if let Some(obj) = self.as_object_mut() {
let mut ctx = serde_json::Map::new();
ctx.insert("sensitive".to_string(), serde_json::Value::String("as:sensitive".into()));
match o_type {
Some(apb::ObjectType::Actor(_)) => {
ctx.insert("counters".to_string(), serde_json::Value::String("https://ns.alemi.dev/as/counters/#".into()));
ctx.insert("followingCount".to_string(), serde_json::Value::String("counters:followingCount".into()));
ctx.insert("followersCount".to_string(), serde_json::Value::String("counters:followersCount".into()));
ctx.insert("statusesCount".to_string(), serde_json::Value::String("counters:statusesCount".into()));
ctx.insert("fe".to_string(), serde_json::Value::String("https://ns.alemi.dev/as/fe/#".into()));
ctx.insert("followingMe".to_string(), serde_json::Value::String("fe:followingMe".into()));
ctx.insert("followedByMe".to_string(), serde_json::Value::String("fe:followedByMe".into()));
},
Some(_) => {
ctx.insert("fe".to_string(), serde_json::Value::String("https://ns.alemi.dev/as/fe/#".into()));
ctx.insert("likedByMe".to_string(), serde_json::Value::String("fe:likedByMe".into()));
},
None => {},
}
obj.insert(
"@context".to_string(),
serde_json::Value::Array(vec![
serde_json::Value::String("https://www.w3.org/ns/activitystreams".into()),
serde_json::Value::Object(ctx),
serde_json::Value::String("https://www.w3.org/ns/activitystreams".into())
]),
);
} else {

View file

@ -2,7 +2,6 @@ pub mod user;
pub mod inbox;
pub mod outbox;
pub mod object;
pub mod context;
pub mod activity;
pub mod application;
pub mod auth;
@ -11,7 +10,7 @@ pub mod well_known;
pub mod jsonld;
pub use jsonld::JsonLD;
use axum::{http::StatusCode, response::IntoResponse, routing::{get, post, put}, Router};
use axum::{http::StatusCode, response::IntoResponse, routing::{get, post}, Router};
pub trait ActivityPubRouter {
fn ap_routes(self) -> Self;
@ -24,23 +23,17 @@ impl ActivityPubRouter for Router<crate::server::Context> {
self
// core server inbox/outbox, maybe for feeds? TODO do we need these?
.route("/", get(ap::application::view))
// fetch route, to debug and retreive remote objects
.route("/dbg", get(ap::application::debug))
// TODO shared inboxes and instance stream will come later, just use users *boxes for now
.route("/inbox", post(ap::inbox::post))
.route("/inbox", get(ap::inbox::get))
.route("/inbox/page", get(ap::inbox::page))
.route("/outbox", post(ap::outbox::post))
.route("/outbox", get(ap::outbox::get))
.route("/outbox/page", get(ap::outbox::page))
// .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::login))
.route("/auth", put(ap::auth::register))
// .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("/.well-known/oauth-authorization-server", get(ap::well_known::oauth_authorization_server))
.route("/nodeinfo/:version", get(ap::well_known::nodeinfo))
// actor routes
.route("/users/:id", get(ap::user::view))
@ -54,28 +47,12 @@ impl ActivityPubRouter for Router<crate::server::Context> {
.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>))
// activities
.route("/activities/:id", get(ap::activity::view))
// context
.route("/context/:id", get(ap::context::get))
.route("/context/:id/page", get(ap::context::page))
// specific object routes
.route("/activities/:id", get(ap::activity::view))
.route("/objects/:id", get(ap::object::view))
.route("/objects/:id/replies", get(ap::object::replies::get))
.route("/objects/:id/replies/page", get(ap::object::replies::page))
//.route("/objects/:id/likes", get(ap::object::likes::get))
//.route("/objects/:id/likes/page", get(ap::object::likes::page))
//.route("/objects/:id/shares", get(ap::object::announces::get))
//.route("/objects/:id/shares/page", get(ap::object::announces::page))
}
}
#[derive(Debug, serde::Deserialize)]
pub struct TryFetch {
#[serde(default)]
pub fetch: bool,
}
#[derive(Debug, serde::Deserialize)]
// TODO i don't really like how pleroma/mastodon do it actually, maybe change this?
pub struct Pagination {

View file

@ -0,0 +1,34 @@
use axum::{extract::{Path, State}, http::StatusCode};
use sea_orm::EntityTrait;
use apb::{ObjectMut, BaseMut, Node};
use crate::{model::{self, object}, server::Context};
use super::{jsonld::LD, JsonLD};
pub fn ap_object(object: model::object::Model) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&object.id))
.set_object_type(Some(object.object_type))
.set_attributed_to(Node::maybe_link(object.attributed_to))
.set_name(object.name.as_deref())
.set_summary(object.summary.as_deref())
.set_content(object.content.as_deref())
.set_context(Node::maybe_link(object.context.clone()))
.set_published(Some(object.published))
.set_to(Node::links(object.to.0.clone()))
.set_bto(Node::Empty)
.set_cc(Node::links(object.cc.0.clone()))
.set_bcc(Node::Empty)
}
pub async fn view(State(ctx) : State<Context>, Path(id): Path<String>) -> Result<JsonLD<serde_json::Value>, StatusCode> {
match object::Entity::find_by_id(ctx.oid(id)).one(ctx.db()).await {
Ok(Some(object)) => Ok(JsonLD(ap_object(object).ld_context())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("error querying for object: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
},
}
}

View file

@ -1,68 +0,0 @@
pub mod replies;
use apb::{CollectionMut, ObjectMut};
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, ModelTrait, QueryFilter};
use crate::{errors::UpubError, model::{self, addressing::Event}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}};
use super::{jsonld::LD, JsonLD, TryFetch};
pub async fn view(
State(ctx): State<Context>,
Path(id): Path<String>,
AuthIdentity(auth): AuthIdentity,
Query(query): Query<TryFetch>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let oid = ctx.uri("objects", id);
if auth.is_local() && query.fetch && !ctx.is_local(&oid) {
let obj = ctx.fetch_object(&oid).await?;
// some implementations serve statuses on different urls than their AP id
if obj.id != oid {
return Err(UpubError::Redirect(crate::url!(ctx, "/objects/{}", ctx.id(&obj.id))));
}
}
let item = model::addressing::Entity::find_addressed(auth.my_id())
.filter(model::object::Column::Id.eq(&oid))
.filter(auth.filter_condition())
.into_model::<Event>()
.one(ctx.db())
.await?
.ok_or_else(UpubError::not_found)?;
let (object, liked) = match item {
Event::Tombstone => return Err(UpubError::not_found()),
Event::Activity(_) => return Err(UpubError::not_found()),
Event::StrayObject { object, liked } => (object, liked),
Event::DeepActivity { activity: _, liked, object } => (object, liked),
};
let attachments = object.find_related(model::attachment::Entity)
.all(ctx.db())
.await?
.into_iter()
.map(|x| x.ap())
.collect::<Vec<serde_json::Value>>();
// let replies =
// serde_json::Value::new_object()
// .set_id(Some(&crate::url!(ctx, "/objects/{id}/replies")))
// .set_collection_type(Some(apb::CollectionType::OrderedCollection))
// .set_first(apb::Node::link(crate::url!(ctx, "/objects/{id}/replies/page")))
// .set_total_items(Some(object.comments as u64));
let likes_count = object.likes as u64;
let mut obj = object.ap().set_attachment(apb::Node::array(attachments));
if let Some(liked) = liked {
obj = obj.set_audience(apb::Node::object( // TODO setting this again ewww...
serde_json::Value::new_object()
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_total_items(Some(likes_count))
.set_ordered_items(apb::Node::links(vec![liked]))
));
}
Ok(JsonLD(obj.ld_context()))
}

View file

@ -1,47 +0,0 @@
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, Condition, PaginatorTrait, QueryFilter};
use crate::{model, routes::activitypub::{JsonLD, Pagination, TryFetch}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}, url};
pub async fn get(
State(ctx): State<Context>,
Path(id): Path<String>,
AuthIdentity(auth): AuthIdentity,
Query(q): Query<TryFetch>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let replies_id = url!(ctx, "/objects/{id}/replies");
let oid = ctx.uri("objects", id);
if auth.is_local() && q.fetch {
ctx.fetch_thread(&oid).await?;
}
let count = model::addressing::Entity::find_addressed(auth.my_id())
.filter(auth.filter_condition())
.filter(model::object::Column::InReplyTo.eq(oid))
.count(ctx.db())
.await?;
crate::server::builders::collection(&replies_id, Some(count))
}
pub async fn page(
State(ctx): State<Context>,
Path(id): Path<String>,
Query(page): Query<Pagination>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
let page_id = url!(ctx, "/objects/{id}/replies/page");
let oid = ctx.uri("objects", id);
crate::server::builders::paginate(
page_id,
Condition::all()
.add(auth.filter_condition())
.add(model::object::Column::InReplyTo.eq(oid)),
ctx.db(),
page,
auth.my_id(),
)
.await
}

View file

@ -1,31 +0,0 @@
use axum::{extract::{Query, State}, http::StatusCode, Json};
use crate::{errors::UpubError, routes::activitypub::{CreationResult, JsonLD, Pagination}, server::{auth::AuthIdentity, Context}, url};
pub async fn get(State(ctx): State<Context>) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::collection(&url!(ctx, "/outbox"), None)
}
pub async fn page(
State(ctx): State<Context>,
Query(page): Query<Pagination>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::paginate(
url!(ctx, "/outbox/page"),
auth.filter_condition(), // TODO filter local only stuff
ctx.db(),
page,
auth.my_id(),
)
.await
}
pub async fn post(
State(_ctx): State<Context>,
AuthIdentity(_auth): AuthIdentity,
Json(_activity): Json<serde_json::Value>,
) -> Result<CreationResult, UpubError> {
// TODO administrative actions may be carried out against this outbox?
Err(StatusCode::NOT_IMPLEMENTED.into())
}

View file

@ -1,47 +1,59 @@
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
use axum::{extract::{Path, Query, State}, http::StatusCode};
use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
use crate::{routes::activitypub::{JsonLD, Pagination}, model, server::Context, url};
use crate::{routes::activitypub::{jsonld::LD, JsonLD, Pagination}, model, server::Context, url};
use model::relation::Column::{Following, Follower};
pub async fn get<const OUTGOING: bool>(
State(ctx): State<Context>,
Path(id): Path<String>,
) -> crate::Result<JsonLD<serde_json::Value>> {
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
let follow___ = if OUTGOING { "following" } else { "followers" };
let count = model::relation::Entity::find()
.filter(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone())))
.filter(Condition::all().add(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone()))))
.count(ctx.db()).await.unwrap_or_else(|e| {
tracing::error!("failed counting {follow___} for {id}: {e}");
0
});
crate::server::builders::collection(&url!(ctx, "/users/{id}/{follow___}"), Some(count))
Ok(JsonLD(
ctx.ap_collection(
&url!(ctx, "/users/{id}/{follow___}"),
Some(count)
).ld_context()
))
}
pub async fn page<const OUTGOING: bool>(
State(ctx): State<Context>,
Path(id): Path<String>,
Query(page): Query<Pagination>,
) -> crate::Result<JsonLD<serde_json::Value>> {
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
let follow___ = if OUTGOING { "following" } else { "followers" };
let limit = page.batch.unwrap_or(20).min(50);
let offset = page.offset.unwrap_or(0);
let following = model::relation::Entity::find()
.filter(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone())))
match model::relation::Entity::find()
.filter(Condition::all().add(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone()))))
.select_only()
.select_column(if OUTGOING { Following } else { Follower })
.limit(limit)
.offset(page.offset.unwrap_or(0))
.into_tuple::<String>()
.all(ctx.db())
.await?;
crate::server::builders::collection_page(
&url!(ctx, "/users/{id}/{follow___}/page"),
offset, limit,
.all(ctx.db()).await
{
Err(e) => {
tracing::error!("error queriying {follow___} for {id}: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
},
Ok(following) => {
Ok(JsonLD(
ctx.ap_collection_page(
&url!(ctx, "/users/{id}/{follow___}"),
offset,
limit,
following.into_iter().map(serde_json::Value::String).collect()
)
).ld_context()
))
},
}
}

View file

@ -1,20 +1,21 @@
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
use sea_orm::{ColumnTrait, Condition, EntityTrait, JoinType, Order, QueryFilter, QueryOrder, QuerySelect, RelationTrait};
use sea_orm::{ColumnTrait, Condition};
use crate::{errors::UpubError, model, routes::activitypub::{JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, url};
use apb::{server::Inbox, ActivityMut, ActivityType, Base, BaseType, ObjectType};
use crate::{errors::UpubError, model, routes::activitypub::{activity::ap_activity, jsonld::LD, object::ap_object, JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, tools::ActivityWithObject, url};
pub async fn get(
State(ctx): State<Context>,
Path(id): Path<String>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
match auth {
Identity::Anonymous => Err(StatusCode::FORBIDDEN.into()),
Identity::Remote(_) => Err(StatusCode::FORBIDDEN.into()),
Identity::Anonymous => Err(StatusCode::FORBIDDEN),
Identity::Remote(_) => Err(StatusCode::FORBIDDEN),
Identity::Local(user) => if ctx.uid(id.clone()) == user {
crate::server::builders::collection(&url!(ctx, "/users/{id}/inbox"), None)
Ok(JsonLD(ctx.ap_collection(&url!(ctx, "/users/{id}/inbox"), None).ld_context()))
} else {
Err(StatusCode::FORBIDDEN.into())
Err(StatusCode::FORBIDDEN)
},
}
}
@ -25,33 +26,103 @@ pub async fn page(
AuthIdentity(auth): AuthIdentity,
Query(page): Query<Pagination>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let Identity::Local(uid) = &auth else {
// local inbox is only for local users
return Err(UpubError::forbidden());
};
if uid != &ctx.uid(id.clone()) {
return Err(UpubError::forbidden());
}
let uid = ctx.uid(id.clone());
match auth {
Identity::Anonymous => Err(StatusCode::FORBIDDEN.into()),
Identity::Remote(_) => Err(StatusCode::FORBIDDEN.into()),
Identity::Local(user) => if uid == user {
let limit = page.batch.unwrap_or(20).min(50);
let offset = page.offset.unwrap_or(0);
let select = model::addressing::Entity::find()
.filter(Condition::all().add(model::addressing::Column::Actor.eq(uid)))
.order_by(model::addressing::Column::Published, Order::Asc)
.select_only();
crate::server::builders::paginate(
url!(ctx, "/users/{id}/inbox/page"),
Condition::any()
.add(model::addressing::Column::Actor.eq(uid))
.add(model::object::Column::AttributedTo.eq(uid))
.add(model::activity::Column::Actor.eq(uid)),
ctx.db(),
page,
auth.my_id(),
)
match crate::tools::Prefixer::new(select)
.add_columns(model::activity::Entity)
.add_columns(model::object::Entity)
.selector
.join(JoinType::LeftJoin, model::activity::Relation::Addressing.def().rev())
.join(JoinType::LeftJoin, model::object::Relation::Activity.def().rev())
.limit(limit)
.offset(offset)
.into_model::<crate::tools::ActivityWithObject>()
.all(ctx.db())
.await
{
Ok(activities) => {
Ok(JsonLD(
ctx.ap_collection_page(
&url!(ctx, "/users/{id}/inbox/page"),
offset, limit,
activities
.into_iter()
.map(|ActivityWithObject { activity, object }| {
ap_activity(activity)
.set_object(apb::Node::maybe_object(object.map(ap_object)))
})
.collect::<Vec<serde_json::Value>>()
).ld_context()
))
},
Err(e) => {
tracing::error!("failed paginating user inbox for {id}: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR.into())
},
}
} else {
Err(StatusCode::FORBIDDEN.into())
},
}
}
pub async fn post(
State(ctx): State<Context>,
Path(_id): Path<String>,
AuthIdentity(_auth): AuthIdentity,
Json(activity): Json<serde_json::Value>,
Json(activity): Json<serde_json::Value>
) -> Result<(), UpubError> {
// POSTing to user inboxes is effectively the same as POSTing to the main inbox
super::super::inbox::post(State(ctx), AuthIdentity(_auth), Json(activity)).await
match activity.base_type() {
None => { Err(StatusCode::BAD_REQUEST.into()) },
Some(BaseType::Link(_x)) => {
tracing::warn!("skipping remote activity: {}", serde_json::to_string_pretty(&activity).unwrap());
Err(StatusCode::UNPROCESSABLE_ENTITY.into()) // we could but not yet
},
Some(BaseType::Object(ObjectType::Activity(ActivityType::Activity))) => {
tracing::warn!("skipping unprocessable base activity: {}", serde_json::to_string_pretty(&activity).unwrap());
Err(StatusCode::UNPROCESSABLE_ENTITY.into()) // won't ingest useless stuff
},
Some(BaseType::Object(ObjectType::Activity(ActivityType::Delete))) =>
Ok(ctx.delete(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Follow))) =>
Ok(ctx.follow(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Accept(_)))) =>
Ok(ctx.accept(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Reject(_)))) =>
Ok(ctx.reject(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Like))) =>
Ok(ctx.like(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Create))) =>
Ok(ctx.create(activity).await?),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Update))) =>
Ok(ctx.update(activity).await?),
Some(BaseType::Object(ObjectType::Activity(_x))) => {
tracing::info!("received unimplemented activity on inbox: {}", serde_json::to_string_pretty(&activity).unwrap());
Err(StatusCode::NOT_IMPLEMENTED.into())
},
Some(_x) => {
tracing::warn!("ignoring non-activity object in inbox: {}", serde_json::to_string_pretty(&activity).unwrap());
Err(StatusCode::UNPROCESSABLE_ENTITY.into())
}
}
}

View file

@ -4,99 +4,69 @@ pub mod outbox;
pub mod following;
use axum::extract::{Path, Query, State};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect, SelectColumns};
use axum::{extract::{Path, State}, http::StatusCode};
use sea_orm::EntityTrait;
use apb::{ActorMut, EndpointsMut, Node};
use crate::{errors::UpubError, model::{self, user}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}, url};
use apb::{PublicKeyMut, ActorMut, DocumentMut, DocumentType, ObjectMut, BaseMut, Node};
use crate::{model::{self, user}, server::Context, url};
use super::{jsonld::LD, JsonLD, TryFetch};
use super::{jsonld::LD, JsonLD};
pub fn ap_user(user: model::user::Model) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&user.id))
.set_actor_type(Some(user.actor_type))
.set_name(user.name.as_deref())
.set_summary(user.summary.as_deref())
.set_icon(Node::maybe_object(user.icon.map(|i|
serde_json::Value::new_object()
.set_document_type(Some(DocumentType::Image))
.set_url(Node::link(i.clone()))
)))
.set_image(Node::maybe_object(user.image.map(|i|
serde_json::Value::new_object()
.set_document_type(Some(DocumentType::Image))
.set_url(Node::link(i.clone()))
)))
.set_published(Some(user.created))
.set_preferred_username(Some(&user.preferred_username))
.set_inbox(Node::maybe_link(user.inbox))
.set_outbox(Node::maybe_link(user.outbox))
.set_following(Node::maybe_link(user.following))
.set_followers(Node::maybe_link(user.followers))
.set_public_key(Node::object(
serde_json::Value::new_object()
.set_id(Some(&format!("{}#main-key", user.id)))
.set_owner(Some(&user.id))
.set_public_key_pem(&user.public_key)
))
.set_discoverable(Some(true))
.set_endpoints(Node::Empty)
}
pub async fn view(
State(ctx) : State<Context>,
AuthIdentity(auth): AuthIdentity,
Path(id): Path<String>,
Query(query): Query<TryFetch>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let mut uid = ctx.uri("users", id.clone());
if auth.is_local() {
if id.starts_with('@') {
if let Some((user, host)) = id.replacen('@', "", 1).split_once('@') {
uid = ctx.webfinger(user, host).await?;
}
}
if query.fetch && !ctx.is_local(&uid) {
ctx.fetch_user(&uid).await?;
}
}
let (followed_by_me, following_me) = match auth.my_id() {
None => (None, None),
Some(my_id) => {
// TODO these two queries are fast because of indexes but still are 2 subqueries for each
// user GET, not even parallelized... should really add these as joins on the main query, so
// that it's one roundtrip only
let followed_by_me = model::relation::Entity::find()
.filter(model::relation::Column::Follower.eq(my_id))
.filter(model::relation::Column::Following.eq(&uid))
.select_only()
.select_column(model::relation::Column::Follower)
.into_tuple::<String>()
.one(ctx.db())
.await?
.map(|_| true);
let following_me = model::relation::Entity::find()
.filter(model::relation::Column::Following.eq(my_id))
.filter(model::relation::Column::Follower.eq(&uid))
.select_only()
.select_column(model::relation::Column::Follower)
.into_tuple::<String>()
.one(ctx.db())
.await?
.map(|_| true);
(followed_by_me, following_me)
},
};
match user::Entity::find_by_id(&uid)
pub async fn view(State(ctx) : State<Context>, Path(id): Path<String>) -> Result<JsonLD<serde_json::Value>, StatusCode> {
match user::Entity::find_by_id(ctx.uid(id.clone()))
.find_also_related(model::config::Entity)
.one(ctx.db()).await?
.one(ctx.db()).await
{
// local user
Some((user_model, Some(cfg))) => {
let mut user = user_model.ap()
Ok(Some((user, Some(_cfg)))) => {
Ok(JsonLD(ap_user(user.clone()) // ew ugly clone TODO
.set_inbox(Node::link(url!(ctx, "/users/{id}/inbox")))
.set_outbox(Node::link(url!(ctx, "/users/{id}/outbox")))
.set_following(Node::link(url!(ctx, "/users/{id}/following")))
.set_followers(Node::link(url!(ctx, "/users/{id}/followers")))
.set_following_me(following_me)
.set_followed_by_me(followed_by_me)
.set_endpoints(Node::object(
serde_json::Value::new_object()
.set_shared_inbox(Some(&url!(ctx, "/inbox")))
));
if !auth.is(&uid) && !cfg.show_followers_count {
user = user.set_followers_count(None);
}
if !auth.is(&uid) && !cfg.show_following_count {
user = user.set_following_count(None);
}
Ok(JsonLD(user.ld_context()))
// .set_public_key(user.public_key) // TODO
.ld_context()
))
},
// remote user
Some((user_model, None)) => Ok(JsonLD(
user_model.ap()
.set_following_me(following_me)
.set_followed_by_me(followed_by_me)
.ld_context()
)),
None => Err(UpubError::not_found()),
Ok(Some((user, None))) => Ok(JsonLD(ap_user(user).ld_context())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("error querying for user: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
},
}
}

View file

@ -1,41 +1,66 @@
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
use sea_orm::{ColumnTrait, Condition};
use sea_orm::{EntityTrait, Order, QueryOrder, QuerySelect};
use apb::{server::Outbox, AcceptType, ActivityType, Base, BaseType, ObjectType, RejectType};
use crate::{errors::UpubError, model, routes::activitypub::{CreationResult, JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, 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>,
Path(id): Path<String>,
) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::collection(&url!(ctx, "/users/{id}/outbox"), None)
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
Ok(JsonLD(
ctx.ap_collection(&url!(ctx, "/users/{id}/outbox"), None).ld_context()
))
}
pub async fn page(
State(ctx): State<Context>,
Path(id): Path<String>,
Query(page): Query<Pagination>,
AuthIdentity(auth): AuthIdentity,
) -> crate::Result<JsonLD<serde_json::Value>> {
let uid = if id.starts_with('+') {
format!("https://{}", id.replacen('+', "", 1).replace('@', "/"))
} else {
ctx.uid(id.clone())
};
crate::server::builders::paginate(
url!(ctx, "/users/{id}/outbox/page"),
Condition::all()
.add(auth.filter_condition())
.add(
Condition::any()
.add(model::activity::Column::Actor.eq(&uid))
.add(model::object::Column::AttributedTo.eq(&uid))
),
ctx.db(),
page,
auth.my_id(),
)
.await
AuthIdentity(_auth): AuthIdentity,
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
let limit = page.batch.unwrap_or(20).min(50);
let offset = page.offset.unwrap_or(0);
// let mut conditions = Condition::any()
// .add(model::addressing::Column::Actor.eq(PUBLIC_TARGET));
// if let Identity::User(ref x) = auth {
// conditions = conditions.add(model::addressing::Column::Actor.eq(x));
// }
// if let Identity::Server(ref x) = auth {
// conditions = conditions.add(model::addressing::Column::Server.eq(x));
// }
match model::activity::Entity::find()
.find_also_related(model::object::Entity)
.order_by(model::activity::Column::Published, Order::Desc)
.limit(limit)
.offset(offset)
.all(ctx.db()).await
{
Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
Ok(items) => {
Ok(JsonLD(
ctx.ap_collection_page(
&url!(ctx, "/users/{id}/outbox/page"),
offset, limit,
items
.into_iter()
.map(|(a, o)| {
let oid = a.object.clone();
super::super::activity::ap_activity(a)
.set_object(match o {
Some(o) => Node::object(super::super::object::ap_object(o)),
None => Node::maybe_link(oid),
})
})
.collect()
).ld_context()
))
},
}
}
pub async fn post(
@ -48,7 +73,6 @@ pub async fn post(
Identity::Anonymous => Err(StatusCode::UNAUTHORIZED.into()),
Identity::Remote(_) => Err(StatusCode::NOT_IMPLEMENTED.into()),
Identity::Local(uid) => if ctx.uid(id.clone()) == uid {
tracing::debug!("processing new local activity: {}", serde_json::to_string(&activity).unwrap_or_default());
match activity.base_type() {
None => Err(StatusCode::BAD_REQUEST.into()),
@ -66,8 +90,8 @@ pub async fn post(
Some(BaseType::Object(ObjectType::Activity(ActivityType::Follow))) =>
Ok(CreationResult(ctx.follow(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Announce))) =>
Ok(CreationResult(ctx.announce(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Undo))) =>
Ok(CreationResult(ctx.undo(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Accept(AcceptType::Accept)))) =>
Ok(CreationResult(ctx.accept(uid, activity).await?)),
@ -75,15 +99,6 @@ pub async fn post(
Some(BaseType::Object(ObjectType::Activity(ActivityType::Reject(RejectType::Reject)))) =>
Ok(CreationResult(ctx.reject(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Undo))) =>
Ok(CreationResult(ctx.undo(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Delete))) =>
Ok(CreationResult(ctx.delete(uid, activity).await?)),
Some(BaseType::Object(ObjectType::Activity(ActivityType::Update))) =>
Ok(CreationResult(ctx.update(uid, activity).await?)),
Some(_) => Err(StatusCode::NOT_IMPLEMENTED.into()),
}
} else {

View file

@ -2,7 +2,7 @@ use axum::{extract::{Path, Query, State}, http::StatusCode, response::{IntoRespo
use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink};
use sea_orm::{EntityTrait, PaginatorTrait};
use crate::{model, server::Context, url, VERSION};
use crate::{model, server::Context, VERSION};
#[derive(serde::Serialize)]
pub struct NodeInfoDiscovery {
@ -20,11 +20,11 @@ pub async fn nodeinfo_discovery(State(ctx): State<Context>) -> Json<NodeInfoDisc
links: vec![
NodeInfoDiscoveryRel {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".into(),
href: crate::url!(ctx, "/nodeinfo/2.0.json"),
href: format!("{}/nodeinfo/2.0.json", ctx.base()),
},
NodeInfoDiscoveryRel {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".into(),
href: crate::url!(ctx, "/nodeinfo/2.1.json"),
href: format!("{}/nodeinfo/2.1.json", ctx.base()),
},
],
})
@ -102,23 +102,6 @@ pub async fn webfinger(State(ctx): State<Context>, Query(query): Query<Webfinger
.replace("acct:", "")
.split_once('@')
{
if user == ctx.domain() && domain == ctx.domain() {
return Ok(JsonRD(JsonResourceDescriptor {
subject: format!("acct:{user}@{domain}"),
aliases: vec![ctx.base()],
links: vec![
JsonResourceDescriptorLink {
rel: "self".to_string(),
link_type: Some("application/ld+json".to_string()),
href: Some(ctx.base()),
properties: jrd::Map::default(),
titles: jrd::Map::default(),
},
],
expires: None,
properties: jrd::Map::default(),
}));
}
let uid = ctx.uid(user.to_string());
match model::user::Entity::find_by_id(uid)
.one(ctx.db())
@ -158,41 +141,6 @@ pub async fn host_meta(State(ctx): State<Context>) -> Response {
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link type="application/xrd+xml" template="{}{}/.well-known/webfinger?resource={{uri}}" rel="lrdd" />
</XRD>"#,
ctx.protocol(), ctx.domain())
ctx.protocol(), ctx.base())
).into_response()
}
#[derive(Debug, serde::Serialize)]
pub struct OauthAuthorizationServerResponse {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
scopes_supported: Vec<String>,
response_types_supported: Vec<String>,
grant_types_supported: Vec<String>,
service_documentation: String,
code_challenge_methods_supported: Vec<String>,
authorization_response_iss_parameter_supported: bool,
}
pub async fn oauth_authorization_server(State(ctx): State<Context>) -> crate::Result<Json<OauthAuthorizationServerResponse>> {
Ok(Json(OauthAuthorizationServerResponse {
issuer: url!(ctx, ""),
authorization_endpoint: url!(ctx, "/auth"),
token_endpoint: "".to_string(),
scopes_supported: vec![
"read:account".to_string(),
"write:account".to_string(),
"read:favorites".to_string(),
"write:favorites".to_string(),
"read:following".to_string(),
"write:following".to_string(),
"write:notes".to_string(),
],
response_types_supported: vec!["code".to_string()],
grant_types_supported: vec!["authorization_code".to_string()],
service_documentation: "".to_string(),
code_challenge_methods_supported: vec![],
authorization_response_iss_parameter_supported: false,
}))
}

View file

@ -41,38 +41,3 @@ pub async fn view(
})),
}
}
// pub struct StatusesQuery {
// /// All results returned will be lesser than this ID. In effect, sets an upper bound on results.
// pub max_id: String,
// /// All results returned will be greater than this ID. In effect, sets a lower bound on results.
// pub since_id: String,
// /// Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
// pub min_id: String,
// /// Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses.
// pub limit: i32,
// /// Filter out statuses without attachments.
// pub only_media: bool,
// /// Filter out statuses in reply to a different account.
// pub exclude_replies: bool,
// /// Filter out boosts from the response.
// pub exclude_reblogs: bool,
// /// Filter for pinned statuses only. Defaults to false, which includes all statuses. Pinned statuses do not receive special priority in the order of the returned results.
// pub pinned: bool,
// /// Filter for statuses using a specific hashtag.
// pub tagged: String,
// }
//
// pub async fn statuses(
// State(ctx): State<Context>,
// AuthIdentity(auth): AuthIdentity,
// Path(id): Path<String>,
// Query(_query): Query<StatusesQuery>,
// ) -> Result<Json<Vec<Status>>, StatusCode> {
// let uid = ctx.uid(id);
// model::addressing::Entity::find_addressed(auth.my_id())
// .filter(model::activity::Column::Actor.eq(uid))
// .filter(auth.filter_condition());
//
// todo!()
// }

View file

@ -1,22 +0,0 @@
use axum::{extract::State, Json};
use crate::server::Context;
pub async fn get(
State(ctx): State<Context>,
) -> crate::Result<Json<mastodon_async_entities::instance::Instance>> {
Ok(Json(mastodon_async_entities::instance::Instance {
uri: ctx.domain().to_string(),
title: ctx.cfg().instance.name.clone(),
description: ctx.cfg().instance.description.clone(),
email: ctx.cfg().instance.contact.as_deref().unwrap_or_default().to_string(),
version: crate::VERSION.to_string(),
urls: None,
stats: None,
thumbnail: None,
languages: None,
contact_account: None,
max_toot_chars: None,
}))
}

View file

@ -1,5 +1,4 @@
pub mod accounts;
pub mod instance;
use axum::{http::StatusCode, routing::{delete, get, patch, post}, Router};
use crate::server::Context;
@ -71,8 +70,6 @@ impl MastodonRouter for Router<Context> {
.route("/profile/avatar", delete(todo))
.route("/profile/header", delete(todo))
.route("/statuses", post(todo))
// ...
.route("/instance", get(mas::instance::get))
)
}
}

View file

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

View file

@ -1,82 +0,0 @@
use sea_orm::{EntityTrait, IntoActiveModel};
#[axum::async_trait]
pub trait Administrable {
async fn register_user(
&self,
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()>;
}
#[axum::async_trait]
impl Administrable for super::Context {
async fn register_user(
&self,
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()> {
let key = openssl::rsa::Rsa::generate(2048).unwrap();
let ap_id = self.uid(username.clone());
let db = self.db();
let domain = self.domain().to_string();
let user_model = crate::model::user::Model {
id: ap_id.clone(),
name: display_name,
domain, summary,
preferred_username: username.clone(),
following: None,
following_count: 0,
followers: None,
followers_count: 0,
statuses_count: 0,
icon: avatar_url,
image: banner_url,
inbox: None,
shared_inbox: None,
outbox: None,
actor_type: apb::ActorType::Person,
created: chrono::Utc::now(),
updated: chrono::Utc::now(),
private_key: Some(std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string()),
public_key: std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string(),
};
crate::model::user::Entity::insert(user_model.into_active_model())
.exec(db)
.await?;
let config_model = crate::model::config::Model {
id: ap_id.clone(),
accept_follow_requests: true,
show_followers_count: true,
show_following_count: true,
show_followers: false,
show_following: false,
};
crate::model::config::Entity::insert(config_model.into_active_model())
.exec(db)
.await?;
let credentials_model = crate::model::credential::Model {
id: ap_id,
email: username,
password,
};
crate::model::credential::Entity::insert(credentials_model.into_active_model())
.exec(db)
.await?;
Ok(())
}
}

View file

@ -1,10 +1,10 @@
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}};
use reqwest::StatusCode;
use std::collections::BTreeMap;
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts, StatusCode}};
use openssl::hash::MessageDigest;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{errors::UpubError, model, server::Context};
use super::{fetcher::Fetcher, httpsign::HttpSignature};
use crate::{model, server::Context};
#[derive(Debug, Clone)]
pub enum Identity {
@ -13,62 +13,6 @@ pub enum Identity {
Remote(String),
}
impl Identity {
pub fn filter_condition(&self) -> Condition {
let base_cond = Condition::any().add(model::addressing::Column::Actor.eq(apb::target::PUBLIC));
match self {
Identity::Anonymous => base_cond,
Identity::Remote(server) => base_cond.add(model::addressing::Column::Server.eq(server)),
// TODO should we allow all users on same server to see? or just specific user??
Identity::Local(uid) => base_cond
.add(model::addressing::Column::Actor.eq(uid))
.add(model::activity::Column::Actor.eq(uid))
.add(model::object::Column::AttributedTo.eq(uid)),
}
}
pub fn my_id(&self) -> Option<&str> {
match self {
Identity::Local(x) => Some(x.as_str()),
_ => None,
}
}
pub fn is(&self, id: &str) -> bool {
match self {
Identity::Anonymous => false,
Identity::Remote(_) => false, // TODO per-actor server auth should check this
Identity::Local(uid) => uid.as_str() == id
}
}
pub fn is_anon(&self) -> bool {
matches!(self, Self::Anonymous)
}
pub fn is_local(&self) -> bool {
matches!(self, Self::Local(_))
}
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote(_))
}
pub fn is_local_user(&self, uid: &str) -> bool {
match self {
Self::Local(x) => x == uid,
_ => false,
}
}
pub fn is_remote_server(&self, uid: &str) -> bool {
match self {
Self::Remote(x) => x == uid,
_ => false,
}
}
}
pub struct AuthIdentity(pub Identity);
#[axum::async_trait]
@ -77,7 +21,7 @@ where
Context: FromRef<S>,
S: Send + Sync,
{
type Rejection = UpubError;
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let ctx = Context::from_ref(state);
@ -91,55 +35,102 @@ where
if auth_header.starts_with("Bearer ") {
match model::session::Entity::find_by_id(auth_header.replace("Bearer ", ""))
.filter(model::session::Column::Expires.gt(chrono::Utc::now()))
.filter(Condition::all().add(model::session::Column::Expires.gt(chrono::Utc::now())))
.one(ctx.db())
.await
{
Ok(Some(x)) => identity = Identity::Local(x.actor),
Ok(None) => return Err(UpubError::unauthorized()),
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!("failed querying user session: {e}");
return Err(UpubError::internal_server_error())
return Err(StatusCode::INTERNAL_SERVER_ERROR)
},
}
}
if let Some(sig) = parts
.headers
.get("Signature")
.map(|v| v.to_str().unwrap_or(""))
{
let mut http_signature = HttpSignature::parse(sig);
// if let Some(sig) = parts
// .headers
// .get("Signature")
// .map(|v| v.to_str().unwrap_or(""))
// {
// let signature = HttpSignature::try_from(sig)?;
// let user_id = signature.key_id.split('#').next().unwrap_or("").to_string();
// let data : String = signature.headers.iter()
// .map(|header| {
// if header == "(request-target)" {
// format!("(request-target): {} {}", parts.method, parts.uri)
// } else {
// format!(
// "{header}: {}",
// parts.headers.get(header)
// .map(|h| h.to_str().unwrap_or(""))
// .unwrap_or("")
// )
// }
// })
// .collect::<Vec<String>>() // TODO can we avoid this unneeded allocation?
// .join("\n");
// TODO assert payload's digest is equal to signature's
let user_id = http_signature.key_id
.split('#')
.next().ok_or(UpubError::bad_request())?
.to_string();
match ctx.fetch_user(&user_id).await {
Ok(user) => match http_signature
.build_from_parts(parts)
.verify(&user.public_key)
{
Ok(true) => identity = Identity::Remote(Context::server(&user_id)),
Ok(false) => tracing::warn!("invalid signature: {http_signature:?}"),
Err(e) => tracing::error!("error verifying signature: {e}"),
},
Err(e) => {
// since most activities are deletions for users we never saw, let's handle this case
// if while fetching we receive a GONE, it means we didn't have this user and it doesn't
// exist anymore, so it must be a deletion we can ignore
if let UpubError::Reqwest(ref x) = e {
if let Some(StatusCode::GONE) = x.status() {
return Err(UpubError::Status(StatusCode::OK)); // 200 so mastodon will shut uppp
}
}
tracing::warn!("could not fetch user (won't verify): {e}");
}
}
}
// let user = ctx.fetch().user(&user_id).await.map_err(|_e| StatusCode::UNAUTHORIZED)?;
// let pubkey = PKey::public_key_from_pem(user.public_key.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
// let mut verifier = Verifier::new(signature.digest(), &pubkey).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
// verifier.update(data.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
// if verifier.verify(signature.signature.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)? {
// identity = Identity::Remote(user_id);
// } else {
// return Err(StatusCode::FORBIDDEN);
// }
// }
Ok(AuthIdentity(identity))
}
}
#[allow(unused)] // TODO am i gonna reimplement http signatures for verification?
pub struct HttpSignature {
key_id: String,
algorithm: String,
headers: Vec<String>,
signature: String,
}
impl HttpSignature {
#[allow(unused)] // TODO am i gonna reimplement http signatures for verification?
pub fn digest(&self) -> MessageDigest {
match self.algorithm.as_str() {
"rsa-sha512" => MessageDigest::sha512(),
"rsa-sha384" => MessageDigest::sha384(),
"rsa-sha256" => MessageDigest::sha256(),
"rsa-sha1" => MessageDigest::sha1(),
_ => {
tracing::error!("unknown digest algorithm, trying with rsa-sha256");
MessageDigest::sha256()
}
}
}
}
impl TryFrom<&str> for HttpSignature {
type Error = StatusCode; // TODO: quite ad hoc...
fn try_from(value: &str) -> Result<Self, Self::Error> {
let parameters : BTreeMap<String, String> = value
.split(',')
.filter_map(|s| { // TODO kinda ugly, can be made nicer?
let (k, v) = s.split_once("=\"")?;
let (k, mut v) = (k.to_string(), v.to_string());
v.pop();
Some((k, v))
}).collect();
let sig = HttpSignature {
key_id: parameters.get("keyId").ok_or(StatusCode::BAD_REQUEST)?.to_string(),
algorithm: parameters.get("algorithm").ok_or(StatusCode::BAD_REQUEST)?.to_string(),
headers: parameters.get("headers").map(|x| x.split(' ').map(|x| x.to_string()).collect()).unwrap_or(vec!["date".to_string()]),
signature: parameters.get("signature").ok_or(StatusCode::BAD_REQUEST)?.to_string(),
};
Ok(sig)
}
}

View file

@ -1,65 +0,0 @@
use apb::{BaseMut, CollectionMut, CollectionPageMut};
use sea_orm::{Condition, DatabaseConnection, QueryFilter, QuerySelect};
use crate::{model::{addressing::Event, attachment::BatchFillable}, routes::activitypub::{jsonld::LD, JsonLD, Pagination}};
pub async fn paginate(
id: String,
filter: Condition,
db: &DatabaseConnection,
page: Pagination,
my_id: Option<&str>,
) -> crate::Result<JsonLD<serde_json::Value>> {
let limit = page.batch.unwrap_or(20).min(50);
let offset = page.offset.unwrap_or(0);
let items = crate::model::addressing::Entity::find_addressed(my_id)
.filter(filter)
// TODO also limit to only local activities
.limit(limit)
.offset(offset)
.into_model::<Event>()
.all(db)
.await?;
let mut attachments = items.load_attachments_batch(db).await?;
let items : Vec<serde_json::Value> = items
.into_iter()
.map(|item| {
let attach = attachments.remove(item.id());
item.ap(attach)
})
.collect();
collection_page(&id, offset, limit, items)
}
pub fn collection_page(id: &str, offset: u64, limit: u64, items: Vec<serde_json::Value>) -> crate::Result<JsonLD<serde_json::Value>> {
let next = if items.len() < limit as usize {
apb::Node::Empty
} else {
apb::Node::link(format!("{id}?offset={}", offset+limit))
};
Ok(JsonLD(
serde_json::Value::new_object()
.set_id(Some(&format!("{id}?offset={offset}")))
.set_collection_type(Some(apb::CollectionType::OrderedCollectionPage))
.set_part_of(apb::Node::link(id.replace("/page", "")))
.set_ordered_items(apb::Node::array(items))
.set_next(next)
.ld_context()
))
}
pub fn collection(id: &str, total_items: Option<u64>) -> crate::Result<JsonLD<serde_json::Value>> {
Ok(JsonLD(
serde_json::Value::new_object()
.set_id(Some(id))
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_first(apb::Node::link(format!("{id}/page")))
.set_total_items(total_items)
.ld_context()
))
}

View file

@ -1,37 +1,37 @@
use std::{collections::BTreeSet, sync::Arc};
use std::sync::Arc;
use apb::{BaseMut, CollectionMut, CollectionPageMut};
use openssl::rsa::Rsa;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, SelectColumns, Set};
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, SelectColumns, Set};
use crate::{config::Config, model, server::fetcher::Fetcher};
use crate::{model, routes::activitypub::jsonld::LD};
use super::dispatcher::Dispatcher;
use super::{dispatcher::Dispatcher, fetcher::Fetcher};
#[derive(Clone)]
pub struct Context(Arc<ContextInner>);
struct ContextInner {
db: DatabaseConnection,
config: Config,
domain: String,
protocol: String,
fetcher: Fetcher,
dispatcher: Dispatcher,
// TODO keep these pre-parsed
app: model::application::Model,
relays: BTreeSet<String>,
}
#[macro_export]
macro_rules! url {
($ctx:expr, $($args: tt)*) => {
format!("{}{}{}", $ctx.protocol(), $ctx.domain(), format!($($args)*))
format!("{}{}{}", $ctx.protocol(), $ctx.base(), format!($($args)*))
};
}
impl Context {
// TODO slim constructor down, maybe make a builder?
pub async fn new(db: DatabaseConnection, mut domain: String, config: Config) -> crate::Result<Self> {
pub async fn new(db: DatabaseConnection, mut domain: String) -> crate::Result<Self> {
let protocol = if domain.starts_with("http://")
{ "http://" } else { "https://" }.to_string();
if domain.ends_with('/') {
@ -40,7 +40,7 @@ impl Context {
if domain.starts_with("http") {
domain = domain.replace("https://", "").replace("http://", "");
}
let dispatcher = Dispatcher::default();
let dispatcher = Dispatcher::new();
for _ in 0..1 { // TODO customize delivery workers amount
dispatcher.spawn(db.clone(), domain.clone(), 30); // TODO ew don't do it this deep and secretly!!
}
@ -63,17 +63,10 @@ impl Context {
}
};
let relays = model::relay::Entity::find()
.select_only()
.select_column(model::relay::Column::Id)
.filter(model::relay::Column::Accepted.eq(true))
.into_tuple::<String>()
.all(&db)
.await?;
let fetcher = Fetcher::new(db.clone(), domain.clone(), app.private_key.clone());
Ok(Context(Arc::new(ContextInner {
db, domain, protocol, app, dispatcher, config,
relays: BTreeSet::from_iter(relays.into_iter()),
db, domain, protocol, app, fetcher, dispatcher,
})))
}
@ -85,11 +78,7 @@ impl Context {
&self.0.db
}
pub fn cfg(&self) -> &Config {
&self.0.config
}
pub fn domain(&self) -> &str {
pub fn base(&self) -> &str {
&self.0.domain
}
@ -97,26 +86,16 @@ impl Context {
&self.0.protocol
}
pub fn base(&self) -> String {
format!("{}{}", self.0.protocol, self.0.domain)
}
pub fn uri(&self, entity: &str, id: String) -> String {
if id.starts_with("http") { // ready-to-use id
id
} else if id.starts_with('+') { // compacted id
// TODO theres already 2 edge cases, i really need to get rid of this
id
.replace('@', "/")
.replace("///", "/@/") // omg wordpress PLEASE AAAAAAAAAAAAAAAAAAAA
.replace("//", "/@") // oops my method sucks!! TODO
.replacen('+', "https://", 1)
.replace(' ', "%20") // omg wordpress
} else { // bare local id
if id.starts_with("http") { id } else {
format!("{}{}/{}/{}", self.0.protocol, self.0.domain, entity, id)
}
}
pub fn fetch(&self) -> &Fetcher {
&self.0.fetcher
}
/// get full user id uri
pub fn uid(&self, id: String) -> String {
self.uri("users", id)
@ -133,14 +112,11 @@ impl Context {
}
/// get bare id, usually an uuid but unspecified
pub fn id(&self, uri: &str) -> String {
if uri.starts_with(&self.0.domain) {
uri.split('/').last().unwrap_or("").to_string()
pub fn id(&self, id: String) -> String {
if id.starts_with(&self.0.domain) {
id.split('/').last().unwrap_or("").to_string()
} else {
uri
.replace("https://", "+")
.replace("http://", "+")
.replace('/', "@")
id
}
}
@ -154,45 +130,33 @@ impl Context {
.to_string()
}
pub fn is_local(&self, id: &str) -> bool {
// TODO consider precalculating once this format!
id.starts_with(&format!("{}{}", self.0.protocol, self.0.domain))
}
pub async fn expand_addressing(&self, targets: Vec<String>) -> crate::Result<Vec<String>> {
let mut out = Vec::new();
for target in targets {
if target.ends_with("/followers") {
let target_id = target.replace("/followers", "");
pub async fn expand_addressing(&self, uid: &str, mut targets: Vec<String>) -> crate::Result<Vec<String>> {
let following_addr = format!("{uid}/followers");
if let Some(i) = targets.iter().position(|x| x == &following_addr) {
targets.remove(i);
model::relation::Entity::find()
.filter(model::relation::Column::Following.eq(target_id))
.filter(Condition::all().add(model::relation::Column::Following.eq(uid.to_string())))
.select_only()
.select_column(model::relation::Column::Follower)
.into_tuple::<String>()
.all(self.db())
.await?
.into_iter()
.for_each(|x| out.push(x));
} else {
out.push(target);
.for_each(|x| targets.push(x));
}
}
Ok(out)
Ok(targets)
}
pub async fn address_to(&self, aid: Option<&str>, oid: Option<&str>, targets: &[String]) -> crate::Result<()> {
let local_activity = aid.map(|x| self.is_local(x)).unwrap_or(false);
let local_object = oid.map(|x| self.is_local(x)).unwrap_or(false);
pub async fn address_to(&self, aid: &str, oid: Option<&str>, targets: &[String]) -> crate::Result<()> {
let addressings : Vec<model::addressing::ActiveModel> = targets
.iter()
.filter(|to| !to.is_empty())
.filter(|to| !to.ends_with("/followers"))
.filter(|to| local_activity || local_object || to.as_str() == apb::target::PUBLIC || self.is_local(to))
.map(|to| model::addressing::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
server: Set(Context::server(to)),
actor: Set(to.to_string()),
activity: Set(aid.map(|x| x.to_string())),
activity: Set(aid.to_string()),
object: Set(oid.map(|x| x.to_string())),
published: Set(chrono::Utc::now()),
})
@ -208,31 +172,23 @@ impl Context {
}
pub async fn deliver_to(&self, aid: &str, from: &str, targets: &[String]) -> crate::Result<()> {
let mut deliveries = Vec::new();
for target in targets.iter()
let deliveries : Vec<model::delivery::ActiveModel> = targets
.iter()
.filter(|to| !to.is_empty())
.filter(|to| Context::server(to) != self.domain())
.filter(|to| Context::server(to) != self.base())
.filter(|to| to != &apb::target::PUBLIC)
{
// TODO fetch concurrently
match self.fetch_user(target).await {
Ok(model::user::Model { inbox: Some(inbox), .. }) => deliveries.push(
model::delivery::ActiveModel {
.map(|to| model::delivery::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
actor: Set(from.to_string()),
// TODO we should resolve each user by id and check its inbox because we can't assume
// it's /users/{id}/inbox for every software, but oh well it's waaaaay easier now
target: Set(inbox),
target: Set(format!("{}/inbox", to)),
activity: Set(aid.to_string()),
created: Set(chrono::Utc::now()),
not_before: Set(chrono::Utc::now()),
attempt: Set(0),
}
),
Ok(_) => tracing::error!("resolved target but missing inbox: '{target}', skipping delivery"),
Err(e) => tracing::error!("failed resolving target inbox: {e}, skipping delivery to '{target}'"),
}
}
})
.collect();
if !deliveries.is_empty() {
model::delivery::Entity::insert_many(deliveries)
@ -245,14 +201,29 @@ impl Context {
Ok(())
}
// TODO should probs not be here
pub fn ap_collection(&self, id: &str, total_items: Option<u64>) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(id))
.set_collection_type(Some(apb::CollectionType::OrderedCollection))
.set_first(apb::Node::link(format!("{id}/page")))
.set_total_items(total_items)
}
// TODO should probs not be here
pub fn ap_collection_page(&self, id: &str, offset: u64, limit: u64, items: Vec<serde_json::Value>) -> serde_json::Value {
serde_json::Value::new_object()
.set_id(Some(&format!("{id}?offset={offset}")))
.set_collection_type(Some(apb::CollectionType::OrderedCollectionPage))
.set_part_of(apb::Node::link(id.replace("/page", "")))
.set_next(apb::Node::link(format!("{id}?offset={}", offset+limit)))
.set_ordered_items(apb::Node::Array(items))
}
pub async fn dispatch(&self, uid: &str, activity_targets: Vec<String>, aid: &str, oid: Option<&str>) -> crate::Result<()> {
let addressed = self.expand_addressing(activity_targets).await?;
self.address_to(Some(aid), oid, &addressed).await?;
let addressed = self.expand_addressing(uid, activity_targets).await?;
self.address_to(aid, oid, &addressed).await?;
self.deliver_to(aid, uid, &addressed).await?;
Ok(())
}
pub fn is_relay(&self, id: &str) -> bool {
self.0.relays.contains(id)
}
}

View file

@ -1,9 +1,11 @@
use reqwest::Method;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
use base64::Engine;
use openssl::{hash::MessageDigest, pkey::{PKey, Private}, sign::Signer};
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
use tokio::{sync::broadcast, task::JoinHandle};
use apb::{ActivityMut, Node};
use crate::{model, routes::activitypub::jsonld::LD, server::{fetcher::Fetcher, Context}};
use crate::{routes::activitypub::{activity::ap_activity, object::ap_object}, errors::UpubError, model, server::Context, VERSION};
pub struct Dispatcher {
waker: broadcast::Sender<()>,
@ -17,15 +19,14 @@ impl Default for Dispatcher {
}
impl Dispatcher {
pub fn new() -> Self { Dispatcher::default() }
pub fn spawn(&self, db: DatabaseConnection, domain: String, poll_interval: u64) -> JoinHandle<()> {
let mut waker = self.waker.subscribe();
let waker = self.waker.subscribe();
tokio::spawn(async move {
loop {
if let Err(e) = worker(&db, &domain, poll_interval, &mut waker).await {
if let Err(e) = worker(db, domain, poll_interval, waker).await {
tracing::error!("delivery worker exited with error: {e}");
}
tokio::time::sleep(std::time::Duration::from_secs(poll_interval * 10)).await;
}
})
}
@ -37,12 +38,12 @@ impl Dispatcher {
}
}
async fn worker(db: &DatabaseConnection, domain: &str, poll_interval: u64, waker: &mut broadcast::Receiver<()>) -> crate::Result<()> {
async fn worker(db: DatabaseConnection, domain: String, poll_interval: u64, mut waker: broadcast::Receiver<()>) -> Result<(), UpubError> {
loop {
let Some(delivery) = model::delivery::Entity::find()
.filter(model::delivery::Column::NotBefore.lte(chrono::Utc::now()))
.filter(Condition::all().add(model::delivery::Column::NotBefore.lte(chrono::Utc::now())))
.order_by(model::delivery::Column::NotBefore, Order::Asc)
.one(db)
.one(&db)
.await?
else {
tokio::select! {
@ -58,7 +59,7 @@ async fn worker(db: &DatabaseConnection, domain: &str, poll_interval: u64, waker
..Default::default()
};
let del = model::delivery::Entity::delete(del_row)
.exec(db)
.exec(&db)
.await?;
if del.rows_affected == 0 {
@ -74,55 +75,31 @@ async fn worker(db: &DatabaseConnection, domain: &str, poll_interval: u64, waker
let payload = match model::activity::Entity::find_by_id(&delivery.activity)
.find_also_related(model::object::Entity)
.one(db)
.one(&db)
.await? // TODO probably should not fail here and at least re-insert the delivery
{
Some((activity, None)) => activity.ap().ld_context(),
Some((activity, Some(object))) => {
let always_embed = matches!(
activity.activity_type,
apb::ActivityType::Create
| apb::ActivityType::Undo
| apb::ActivityType::Update
| apb::ActivityType::Accept(_)
| apb::ActivityType::Reject(_)
);
if always_embed {
activity.ap().set_object(Node::object(object.ap())).ld_context()
} else {
activity.ap().ld_context()
}
},
Some((activity, Some(object))) => ap_activity(activity).set_object(Node::object(ap_object(object))),
Some((activity, None)) => ap_activity(activity),
None => {
tracing::warn!("skipping dispatch for deleted object {}", delivery.activity);
continue;
},
};
let key = if delivery.actor == format!("https://{domain}") {
let Some(model::application::Model { private_key: key, .. }) = model::application::Entity::find()
.one(db).await?
else {
tracing::error!("no private key configured for application");
continue;
};
key
} else {
let Some(model::user::Model{ private_key: Some(key), .. }) = model::user::Entity::find_by_id(&delivery.actor)
.one(db).await?
.one(&db).await?
else {
tracing::error!("can not dispatch activity for user without private key: {}", delivery.actor);
continue;
};
key
let Ok(key) = PKey::private_key_from_pem(key.as_bytes())
else {
tracing::error!("failed parsing private key for user {}", delivery.actor);
continue;
};
if let Err(e) = Context::request(
Method::POST, &delivery.target,
Some(&serde_json::to_string(&payload).unwrap()),
&delivery.actor, &key, domain
).await {
if let Err(e) = deliver(&key, &delivery.target, &delivery.actor, payload, &domain).await {
tracing::warn!("failed delivery of {} to {} : {e}", delivery.activity, delivery.target);
let new_delivery = model::delivery::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
@ -133,7 +110,62 @@ async fn worker(db: &DatabaseConnection, domain: &str, poll_interval: u64, waker
created: sea_orm::ActiveValue::Set(delivery.created),
attempt: sea_orm::ActiveValue::Set(delivery.attempt + 1),
};
model::delivery::Entity::insert(new_delivery).exec(db).await?;
model::delivery::Entity::insert(new_delivery).exec(&db).await?;
}
}
}
async fn deliver(key: &PKey<Private>, to: &str, from: &str, payload: serde_json::Value, domain: &str) -> Result<(), UpubError> {
let payload = serde_json::to_string(&payload).unwrap();
let digest = format!("sha-256={}", base64::prelude::BASE64_STANDARD.encode(openssl::sha::sha256(payload.as_bytes())));
let host = Context::server(to);
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); // lmao @ "GMT"
let path = to.replace("https://", "").replace("http://", "").replace(&host, "");
// let headers : BTreeMap<String, String> = [
// ("Host".to_string(), host.clone()),
// ("Date".to_string(), date.clone()),
// ("Digest".to_string(), digest.clone()),
// ].into();
// let signature_header = Config::new()
// .dont_use_created_field()
// .require_header("host")
// .require_header("date")
// .require_header("digest")
// .begin_sign("POST", &path, headers)
// .unwrap()
// .sign(format!("{from}#main-key"), |to_sign| {
// tracing::info!("signing '{to_sign}'");
// let mut signer = Signer::new(MessageDigest::sha256(), key)?;
// signer.update(to_sign.as_bytes())?;
// let signature = base64::prelude::BASE64_URL_SAFE.encode(signer.sign_to_vec()?);
// Ok(signature) as Result<_, UpubError>
// })
// .unwrap()
// .signature_header();
let signature_header = {
let to_sign = format!("(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}");
let mut signer = Signer::new(MessageDigest::sha256(), key)?;
signer.update(to_sign.as_bytes())?;
let signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec()?);
format!("keyId=\"{from}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"")
};
reqwest::Client::new()
.post(to)
.header("Host", host)
.header("Date", date)
.header("Digest", digest)
.header("Signature", signature_header)
.header(CONTENT_TYPE, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
.header(USER_AGENT, format!("upub+{VERSION} ({domain})")) // TODO put instance admin email
.body(payload)
.send()
.await?
.error_for_status()?;
Ok(())
}

View file

@ -1,391 +1,53 @@
use std::collections::BTreeMap;
use openssl::pkey::{PKey, Private};
use reqwest::header::USER_AGENT;
use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel};
use apb::{target::Addressed, Activity, Base, Collection, CollectionPage, Link, Object};
use base64::Engine;
use reqwest::{header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, Method, Response};
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter};
use crate::{VERSION, model};
use crate::{errors::UpubError, model, VERSION};
use super::{httpsign::HttpSignature, Context};
#[derive(Debug, thiserror::Error)]
pub enum FetchError {
#[error("could not dereference resource: {0}")]
Network(#[from] reqwest::Error),
#[axum::async_trait]
pub trait Fetcher {
async fn webfinger(&self, user: &str, host: &str) -> crate::Result<String>;
#[error("error operating on database: {0}")]
Database(#[from] sea_orm::DbErr),
async fn fetch_user(&self, id: &str) -> crate::Result<model::user::Model>;
async fn pull_user(&self, id: &str) -> crate::Result<model::user::Model>;
#[error("missing field when constructing object: {0}")]
Field(#[from] model::FieldError),
}
async fn fetch_object(&self, id: &str) -> crate::Result<model::object::Model>;
async fn pull_object(&self, id: &str) -> crate::Result<model::object::Model>;
pub struct Fetcher {
db: DatabaseConnection,
_key: PKey<Private>, // TODO store pre-parsed
domain: String, // TODO merge directly with Context so we don't need to copy this
}
async fn fetch_activity(&self, id: &str) -> crate::Result<model::activity::Model>;
async fn pull_activity(&self, id: &str) -> crate::Result<model::activity::Model>;
impl Fetcher {
pub fn new(db: DatabaseConnection, domain: String, key: String) -> Self {
Fetcher { db, domain, _key: PKey::private_key_from_pem(key.as_bytes()).unwrap() }
}
async fn fetch_thread(&self, id: &str) -> crate::Result<()>;
pub async fn user(&self, id: &str) -> Result<model::user::Model, FetchError> {
if let Some(x) = model::user::Entity::find_by_id(id).one(&self.db).await? {
return Ok(x); // already in db, easy
}
async fn request(
method: reqwest::Method,
url: &str,
payload: Option<&str>,
from: &str,
key: &str,
domain: &str,
) -> crate::Result<Response> {
let host = Context::server(url);
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); // lmao @ "GMT"
let path = url.replace("https://", "").replace("http://", "").replace(&host, "");
let digest = format!("sha-256={}",
base64::prelude::BASE64_STANDARD.encode(
openssl::sha::sha256(payload.unwrap_or("").as_bytes())
)
);
let headers = vec!["(request-target)", "host", "date", "digest"];
let headers_map : BTreeMap<String, String> = [
("host".to_string(), host.clone()),
("date".to_string(), date.clone()),
("digest".to_string(), digest.clone()),
].into();
let mut signer = HttpSignature::new(
format!("{from}#main-key"), // TODO don't hardcode #main-key
//"hs2019".to_string(), // pixelfeed/iceshrimp made me go back
"rsa-sha256".to_string(),
&headers,
);
signer
.build_manually(&method.to_string().to_lowercase(), &path, headers_map)
.sign(key)?;
let response = reqwest::Client::new()
.request(method.clone(), url)
.header(ACCEPT, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
.header(CONTENT_TYPE, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
.header(USER_AGENT, format!("upub+{VERSION} ({domain})"))
.header("Host", host.clone())
.header("Date", date.clone())
.header("Digest", digest)
.header("Signature", signer.header())
.body(payload.unwrap_or("").to_string())
// TODO sign http fetches, we got the app key and db to get user keys just in case
tracing::info!("fetching {id}");
let user = reqwest::Client::new()
.get(id)
.header(USER_AGENT, format!("upub+{VERSION} ({})", self.domain)) // TODO put instance admin email
.send()
.await?;
// TODO this is ugly but i want to see the raw response text when it's a failure
match response.error_for_status_ref() {
Ok(_) => Ok(response),
Err(e) => Err(UpubError::FetchError(e, response.text().await?)),
}
}
}
#[axum::async_trait]
impl Fetcher for Context {
async fn webfinger(&self, user: &str, host: &str) -> crate::Result<String> {
let subject = format!("acct:{user}@{host}");
let webfinger_uri = format!("https://{host}/.well-known/webfinger?resource={subject}");
let resource = reqwest::Client::new()
.get(webfinger_uri)
.header(ACCEPT, "application/jrd+json")
.header(USER_AGENT, format!("upub+{VERSION} ({})", self.domain()))
.send()
.await?
.json::<jrd::JsonResourceDescriptor>()
.await?;
if resource.subject != subject {
return Err(UpubError::unprocessable());
}
for link in resource.links {
if link.rel == "self" {
if let Some(href) = link.href {
return Ok(href);
}
}
}
if let Some(alias) = resource.aliases.into_iter().next() {
return Ok(alias);
}
Err(UpubError::not_found())
}
async fn fetch_user(&self, id: &str) -> crate::Result<model::user::Model> {
if let Some(x) = model::user::Entity::find_by_id(id).one(self.db()).await? {
return Ok(x); // already in db, easy
}
let user_model = self.pull_user(id).await?;
// TODO this may fail: while fetching, remote server may fetch our service actor.
// if it does so with http signature, we will fetch that actor in background
// meaning that, once we reach here, it's already inserted and returns an UNIQUE error
model::user::Entity::insert(user_model.clone().into_active_model())
.exec(self.db()).await?;
Ok(user_model)
}
async fn pull_user(&self, id: &str) -> crate::Result<model::user::Model> {
let user = Self::request(
Method::GET, id, None, &format!("https://{}", self.domain()), &self.app().private_key, self.domain(),
).await?.json::<serde_json::Value>().await?;
let mut user_model = model::user::Model::new(&user)?;
// TODO try fetching these numbers from audience/generator fields to avoid making 2 more GETs
if let Some(followers_url) = &user_model.followers {
let req = Self::request(
Method::GET, followers_url, None,
&format!("https://{}", self.domain()), &self.app().private_key, self.domain(),
).await;
if let Ok(res) = req {
if let Ok(user_followers) = res.json::<serde_json::Value>().await {
if let Some(total) = user_followers.total_items() {
user_model.followers_count = total as i64;
}
}
}
}
if let Some(following_url) = &user_model.following {
let req = Self::request(
Method::GET, following_url, None,
&format!("https://{}", self.domain()), &self.app().private_key, self.domain(),
).await;
if let Ok(res) = req {
if let Ok(user_following) = res.json::<serde_json::Value>().await {
if let Some(total) = user_following.total_items() {
user_model.following_count = total as i64;
}
}
}
}
Ok(user_model)
}
async fn fetch_activity(&self, id: &str) -> crate::Result<model::activity::Model> {
if let Some(x) = model::activity::Entity::find_by_id(id).one(self.db()).await? {
return Ok(x); // already in db, easy
}
let activity_model = self.pull_activity(id).await?;
model::activity::Entity::insert(activity_model.clone().into_active_model())
.exec(self.db()).await?;
let addressed = activity_model.addressed();
let expanded_addresses = self.expand_addressing(addressed).await?;
self.address_to(Some(&activity_model.id), None, &expanded_addresses).await?;
Ok(activity_model)
}
async fn pull_activity(&self, id: &str) -> crate::Result<model::activity::Model> {
let activity = Self::request(
Method::GET, id, None, &format!("https://{}", self.domain()), &self.app().private_key, self.domain(),
).await?.json::<serde_json::Value>().await?;
if let Some(activity_actor) = activity.actor().id() {
if let Err(e) = self.fetch_user(&activity_actor).await {
tracing::warn!("could not get actor of fetched activity: {e}");
}
}
if let Some(activity_object) = activity.object().id() {
if let Err(e) = self.fetch_object(&activity_object).await {
tracing::warn!("could not get object of fetched activity: {e}");
}
}
let activity_model = model::activity::Model::new(&activity)?;
Ok(activity_model)
}
async fn fetch_thread(&self, id: &str) -> crate::Result<()> {
crawl_replies(self, id, 0).await
}
async fn fetch_object(&self, id: &str) -> crate::Result<model::object::Model> {
fetch_object_inner(self, id, 0).await
}
async fn pull_object(&self, id: &str) -> crate::Result<model::object::Model> {
let object = Context::request(
Method::GET, id, None, &format!("https://{}", self.domain()), &self.app().private_key, self.domain(),
).await?.json::<serde_json::Value>().await?;
Ok(model::object::Model::new(&object)?)
}
}
#[async_recursion::async_recursion]
async fn crawl_replies(ctx: &Context, id: &str, depth: usize) -> crate::Result<()> {
tracing::info!("crawling replies of '{id}'");
let object = Context::request(
Method::GET, id, None, &format!("https://{}", ctx.domain()), &ctx.app().private_key, ctx.domain(),
).await?.json::<serde_json::Value>().await?;
let object_model = model::object::Model::new(&object)?;
match model::object::Entity::insert(object_model.into_active_model())
.exec(ctx.db()).await
{
Ok(_) => {},
Err(sea_orm::DbErr::RecordNotInserted) => {},
Err(sea_orm::DbErr::Exec(_)) => {}, // ughhh bad fix for sqlite
Err(e) => return Err(e.into()),
}
if depth > 16 {
tracing::warn!("stopping thread crawling: too deep!");
return Ok(());
}
let mut page_url = match object.replies().get() {
Some(serde_json::Value::String(x)) => {
let replies = Context::request(
Method::GET, x, None, &format!("https://{}", ctx.domain()), &ctx.app().private_key, ctx.domain(),
).await?.json::<serde_json::Value>().await?;
replies.first().id()
},
Some(serde_json::Value::Object(x)) => {
let obj = serde_json::Value::Object(x.clone()); // lol putting it back, TODO!
obj.first().id()
},
_ => return Ok(()),
};
while let Some(ref url) = page_url {
let replies = Context::request(
Method::GET, url, None, &format!("https://{}", ctx.domain()), &ctx.app().private_key, ctx.domain(),
).await?.json::<serde_json::Value>().await?;
for reply in replies.items() {
// TODO right now it crawls one by one, could be made in parallel but would be quite more
// abusive, so i'll keep it like this while i try it out
crawl_replies(ctx, reply.href(), depth + 1).await?;
}
page_url = replies.next().id();
}
Ok(())
}
#[async_recursion::async_recursion]
async fn fetch_object_inner(ctx: &Context, id: &str, depth: usize) -> crate::Result<model::object::Model> {
if let Some(x) = model::object::Entity::find_by_id(id).one(ctx.db()).await? {
return Ok(x); // already in db, easy
}
let object = Context::request(
Method::GET, id, None, &format!("https://{}", ctx.domain()), &ctx.app().private_key, ctx.domain(),
).await?.json::<serde_json::Value>().await?;
if let Some(oid) = object.id() {
if oid != id {
if let Some(x) = model::object::Entity::find_by_id(oid).one(ctx.db()).await? {
return Ok(x); // already in db, but with id different that given url
}
}
}
if let Some(attributed_to) = object.attributed_to().id() {
if let Err(e) = ctx.fetch_user(&attributed_to).await {
tracing::warn!("could not get actor of fetched object: {e}");
}
model::user::Entity::update_many()
.col_expr(model::user::Column::StatusesCount, Expr::col(model::user::Column::StatusesCount).add(1))
.filter(model::user::Column::Id.eq(&attributed_to))
.exec(ctx.db())
.await?;
}
let addressed = object.addressed();
let mut object_model = model::object::Model::new(&object)?;
if let Some(reply) = &object_model.in_reply_to {
if depth <= 16 {
fetch_object_inner(ctx, reply, depth + 1).await?;
model::object::Entity::update_many()
.filter(model::object::Column::Id.eq(reply))
.col_expr(model::object::Column::Comments, Expr::col(model::object::Column::Comments).add(1))
.exec(ctx.db())
.await?;
} else {
tracing::warn!("thread deeper than 16, giving up fetching more replies");
}
}
// fix context also for remote posts
// TODO this is not really appropriate because we're mirroring incorrectly remote objects, but
// it makes it SOO MUCH EASIER for us to fetch threads and stuff, so we're filling it for them
match (&object_model.in_reply_to, &object_model.context) {
(Some(reply_id), None) => // get context from replied object
object_model.context = fetch_object_inner(ctx, reply_id, depth + 1).await?.context,
(None, None) => // generate a new context
object_model.context = Some(crate::url!(ctx, "/context/{}", uuid::Uuid::new_v4().to_string())),
(_, Some(_)) => {}, // leave it as set by user
}
for attachment in object.attachment() {
let attachment_model = model::attachment::ActiveModel::new(&attachment, object_model.id.clone(), None)?;
model::attachment::Entity::insert(attachment_model)
.exec(ctx.db())
.await?;
}
// lemmy sends us an image field in posts, treat it like an attachment i'd say
if let Some(img) = object.image().get() {
// TODO lemmy doesnt tell us the media type but we use it to display the thing...
let img_url = img.url().id().unwrap_or_default();
let media_type = if img_url.ends_with("png") {
Some("image/png".to_string())
} else if img_url.ends_with("webp") {
Some("image/webp".to_string())
} else if img_url.ends_with("jpeg") || img_url.ends_with("jpg") {
Some("image/jpeg".to_string())
} else {
None
};
let attachment_model = model::attachment::ActiveModel::new(img, object_model.id.clone(), media_type)?;
model::attachment::Entity::insert(attachment_model)
.exec(ctx.db())
.await?;
}
let expanded_addresses = ctx.expand_addressing(addressed).await?;
ctx.address_to(None, Some(&object_model.id), &expanded_addresses).await?;
model::object::Entity::insert(object_model.clone().into_active_model())
.exec(ctx.db()).await?;
Ok(object_model)
}
#[axum::async_trait]
pub trait Fetchable : Sync + Send {
async fn fetch(&mut self, ctx: &crate::server::Context) -> crate::Result<&mut Self>;
}
#[axum::async_trait]
impl Fetchable for apb::Node<serde_json::Value> {
async fn fetch(&mut self, ctx: &crate::server::Context) -> crate::Result<&mut Self> {
if let apb::Node::Link(uri) = self {
let from = format!("{}{}", ctx.protocol(), ctx.domain()); // TODO helper to avoid this?
let pkey = &ctx.app().private_key;
*self = Context::request(Method::GET, uri.href(), None, &from, pkey, ctx.domain())
.await?
.json::<serde_json::Value>()
.await?
.into();
}
.await?;
Ok(self)
let user_model = model::user::Model::new(&user)?;
model::user::Entity::insert(user_model.clone().into_active_model())
.exec(&self.db).await?;
Ok(user_model)
}
}

View file

@ -1,130 +0,0 @@
use std::collections::BTreeMap;
use axum::http::request::Parts;
use base64::Engine;
use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier};
#[derive(Debug, Clone, Default)]
pub struct HttpSignature {
pub key_id: String,
pub algorithm: String,
pub headers: Vec<String>,
pub signature: String,
pub control: String,
}
impl HttpSignature {
pub fn new(key_id: String, algorithm: String, headers: &[&str]) -> Self {
HttpSignature {
key_id, algorithm,
headers: headers.iter().map(|x| x.to_string()).collect(),
signature: String::new(),
control: String::new(),
}
}
pub fn parse(header: &str) -> Self {
let mut sig = HttpSignature::default();
header.split(',')
.filter_map(|x| x.split_once('='))
.map(|(k, v)| (k, v.trim_end_matches('"').trim_matches('"')))
.for_each(|(k, v)| match k {
"keyId" => sig.key_id = v.to_string(),
"algorithm" => sig.algorithm = v.to_string(),
"signature" => sig.signature = v.to_string(),
"headers" => sig.headers = v.split(' ').map(|x| x.to_string()).collect(),
_ => tracing::warn!("unexpected field in http signature: '{k}=\"{v}\"'"),
});
sig
}
pub fn header(&self) -> String {
format!(
"keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"",
self.key_id, self.algorithm, self.headers.join(" "), self.signature,
)
}
pub fn build_manually(&mut self, method: &str, target: &str, mut headers: BTreeMap<String, String>) -> &mut Self {
let mut out = Vec::new();
for header in &self.headers {
match header.as_str() {
"(request-target)" => out.push(format!("(request-target): {method} {target}")),
// TODO other pseudo-headers
_ => out.push(
format!("{header}: {}", headers.remove(header).unwrap_or_default())
),
}
}
self.control = out.join("\n");
self
}
pub fn build_from_parts(&mut self, parts: &Parts) -> &mut Self {
let mut out = Vec::new();
for header in self.headers.iter() {
match header.as_str() {
"(request-target)" => out.push(
format!(
"(request-target): {} {}",
parts.method.to_string().to_lowercase(),
parts.uri.path_and_query().map(|x| x.as_str()).unwrap_or("/")
)
),
// TODO other pseudo-headers,
_ => out.push(format!("{}: {}",
header.to_lowercase(),
parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("")
)),
}
}
self.control = out.join("\n");
self
}
pub fn verify(&self, key: &str) -> crate::Result<bool> {
let pubkey = PKey::public_key_from_pem(key.as_bytes())?;
let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?;
let signature = base64::prelude::BASE64_STANDARD.decode(&self.signature)?;
Ok(verifier.verify_oneshot(&signature, self.control.as_bytes())?)
}
pub fn sign(&mut self, key: &str) -> crate::Result<&str> {
let privkey = PKey::private_key_from_pem(key.as_bytes())?;
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &privkey)?;
signer.update(self.control.as_bytes())?;
self.signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec()?);
Ok(&self.signature)
}
}
#[cfg(test)]
mod test {
#[test]
fn http_signature_signs_and_verifies() {
let key = openssl::rsa::Rsa::generate(2048).unwrap();
let private_key = std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string();
let public_key = std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string();
let mut signer = super::HttpSignature {
key_id: "test".to_string(),
algorithm: "rsa-sha256".to_string(),
headers: vec![
"(request-target)".to_string(),
"host".to_string(),
"date".to_string(),
],
signature: String::new(),
control: String::new(),
};
signer
.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into())
.sign(&private_key)
.unwrap();
let mut verifier = super::HttpSignature::parse(&signer.header());
verifier.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into());
assert!(verifier.verify(&public_key).unwrap());
}
}

View file

@ -1,10 +1,9 @@
use apb::{target::Addressed, Activity, Base, Object};
use reqwest::StatusCode;
use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect, SelectColumns, Set};
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use crate::{errors::{LoggableError, UpubError}, model::{self, FieldError}};
use crate::{errors::{LoggableError, UpubError}, model};
use super::{fetcher::Fetcher, Context};
use super::Context;
#[axum::async_trait]
@ -12,170 +11,67 @@ impl apb::server::Inbox for Context {
type Error = UpubError;
type Activity = serde_json::Value;
async fn create(&self, server: String, activity: serde_json::Value) -> crate::Result<()> {
async fn create(&self, activity: serde_json::Value) -> crate::Result<()> {
let activity_model = model::activity::Model::new(&activity)?;
let aid = activity_model.id.clone();
let activity_targets = activity.addressed();
let Some(object_node) = activity.object().extract() else {
// TODO we could process non-embedded activities or arrays but im lazy rn
tracing::error!("refusing to process activity without embedded object: {}", serde_json::to_string_pretty(&activity).unwrap());
return Err(UpubError::unprocessable());
};
let mut object_model = model::object::Model::new(&object_node)?;
let object_model = model::object::Model::new(&object_node)?;
let aid = activity_model.id.clone();
let oid = object_model.id.clone();
let uid = object_model.attributed_to.clone();
// make sure we're allowed to edit this object
if let Some(object_author) = &object_model.attributed_to {
if server != Context::server(object_author) {
return Err(UpubError::forbidden());
}
} else if server != Context::server(&object_model.id) {
return Err(UpubError::forbidden());
};
// fix context also for remote posts
// TODO this is not really appropriate because we're mirroring incorrectly remote objects, but
// it makes it SOO MUCH EASIER for us to fetch threads and stuff, so we're filling it for them
match (&object_model.in_reply_to, &object_model.context) {
(Some(reply_id), None) => // get context from replied object
object_model.context = self.fetch_object(reply_id).await?.context,
(None, None) => // generate a new context
object_model.context = Some(crate::url!(self, "/context/{}", uuid::Uuid::new_v4().to_string())),
(_, Some(_)) => {}, // leave it as set by user
}
// update replies counter
if let Some(ref in_reply_to) = object_model.in_reply_to {
if self.fetch_object(in_reply_to).await.is_ok() {
model::object::Entity::update_many()
.filter(model::object::Column::Id.eq(in_reply_to))
.col_expr(model::object::Column::Comments, Expr::col(model::object::Column::Comments).add(1))
.exec(self.db())
.await?;
}
}
model::object::Entity::insert(object_model.into_active_model()).exec(self.db()).await?;
model::activity::Entity::insert(activity_model.into_active_model()).exec(self.db()).await?;
for attachment in object_node.attachment() {
let attachment_model = model::attachment::ActiveModel::new(&attachment, oid.clone(), None)?;
model::attachment::Entity::insert(attachment_model)
.exec(self.db())
.await?;
}
// lemmy sends us an image field in posts, treat it like an attachment i'd say
if let Some(img) = object_node.image().get() {
// TODO lemmy doesnt tell us the media type but we use it to display the thing...
let img_url = img.url().id().unwrap_or_default();
let media_type = if img_url.ends_with("png") {
Some("image/png".to_string())
} else if img_url.ends_with("webp") {
Some("image/webp".to_string())
} else if img_url.ends_with("jpeg") || img_url.ends_with("jpg") {
Some("image/jpeg".to_string())
} else {
None
};
let attachment_model = model::attachment::ActiveModel::new(img, oid.clone(), media_type)?;
model::attachment::Entity::insert(attachment_model)
.exec(self.db())
.await?;
}
// TODO can we even receive anonymous objects?
if let Some(object_author) = uid {
model::user::Entity::update_many()
.col_expr(model::user::Column::StatusesCount, Expr::col(model::user::Column::StatusesCount).add(1))
.filter(model::user::Column::Id.eq(&object_author))
.exec(self.db())
.await?;
}
let expanded_addressing = self.expand_addressing(activity.addressed()).await?;
self.address_to(Some(&aid), Some(&oid), &expanded_addressing).await?;
self.address_to(&aid, Some(&oid), &activity_targets).await?;
tracing::info!("{} posted {}", aid, oid);
Ok(())
}
async fn like(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
let aid = activity.id().ok_or(UpubError::bad_request())?;
let uid = activity.actor().id().ok_or(UpubError::bad_request())?;
async fn like(&self, activity: serde_json::Value) -> crate::Result<()> {
let aid = activity.actor().id().ok_or(UpubError::bad_request())?;
let oid = activity.object().id().ok_or(UpubError::bad_request())?;
if let Err(e) = self.fetch_object(&oid).await {
tracing::warn!("failed fetching liked object: {e}");
}
let like = model::like::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
actor: sea_orm::Set(uid.clone()),
actor: sea_orm::Set(aid.clone()),
likes: sea_orm::Set(oid.clone()),
date: sea_orm::Set(activity.published().unwrap_or(chrono::Utc::now())),
date: sea_orm::Set(chrono::Utc::now()),
};
match model::like::Entity::insert(like).exec(self.db()).await {
Err(sea_orm::DbErr::RecordNotInserted) => Err(UpubError::not_modified()),
Err(sea_orm::DbErr::Exec(_)) => Err(UpubError::not_modified()), // bad fix for sqlite
Err(e) => {
tracing::error!("unexpected error procesing like from {uid} to {oid}: {e}");
tracing::error!("unexpected error procesing like from {aid} to {oid}: {e}");
Err(UpubError::internal_server_error())
}
Ok(_) => {
let activity_model = model::activity::Model::new(&activity)?.into_active_model();
model::activity::Entity::insert(activity_model)
.exec(self.db())
.await?;
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
if expanded_addressing.is_empty() { // WHY MASTODON!!!!!!!
expanded_addressing.push(
model::object::Entity::find_by_id(&oid)
.select_only()
.select_column(model::object::Column::AttributedTo)
.into_tuple::<String>()
.one(self.db())
.await?
.ok_or_else(UpubError::not_found)?
);
}
self.address_to(Some(aid), None, &expanded_addressing).await?;
model::object::Entity::update_many()
.col_expr(model::object::Column::Likes, Expr::col(model::object::Column::Likes).add(1))
.filter(model::object::Column::Id.eq(oid.clone()))
.exec(self.db())
.await?;
tracing::info!("{} liked {}", uid, oid);
tracing::info!("{} liked {}", aid, oid);
Ok(())
},
}
}
async fn follow(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
async fn follow(&self, activity: serde_json::Value) -> crate::Result<()> {
let activity_targets = activity.addressed();
let activity_model = model::activity::Model::new(&activity)?;
let aid = activity_model.id.clone();
let target_user_id = activity_model.object
.as_deref()
.ok_or_else(UpubError::bad_request)?
.to_string();
tracing::info!("{} wants to follow {}", activity_model.actor, target_user_id);
tracing::info!("{} wants to follow {}", activity_model.actor, activity_model.object.as_deref().unwrap_or("<no-one???>"));
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
if !expanded_addressing.contains(&target_user_id) {
expanded_addressing.push(target_user_id);
}
self.address_to(Some(&aid), None, &expanded_addressing).await?;
self.address_to(&aid, None, &activity_targets).await?;
Ok(())
}
async fn accept(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
async fn accept(&self, activity: serde_json::Value) -> crate::Result<()> {
// TODO what about TentativeAccept
let activity_model = model::activity::Model::new(&activity)?;
if let Some(mut r) = model::relay::Entity::find_by_id(&activity_model.actor)
.one(self.db())
.await?
{
r.accepted = true;
model::relay::Entity::update(r.into_active_model()).exec(self.db()).await?;
model::activity::Entity::insert(activity_model.clone().into_active_model())
.exec(self.db())
.await?;
tracing::info!("relay {} is now broadcasting to us", activity_model.actor);
return Ok(());
}
let Some(follow_request_id) = &activity_model.object else {
let Some(follow_request_id) = activity_model.object else {
return Err(UpubError::bad_request());
};
let Some(follow_activity) = model::activity::Entity::find_by_id(follow_request_id)
@ -189,37 +85,22 @@ impl apb::server::Inbox for Context {
tracing::info!("{} accepted follow request by {}", activity_model.actor, follow_activity.actor);
model::activity::Entity::insert(activity_model.clone().into_active_model())
.exec(self.db())
.await?;
model::user::Entity::update_many()
.col_expr(
model::user::Column::FollowingCount,
Expr::col(model::user::Column::FollowingCount).add(1)
)
.filter(model::user::Column::Id.eq(&follow_activity.actor))
.exec(self.db())
.await?;
model::relation::Entity::insert(
model::relation::ActiveModel {
follower: Set(follow_activity.actor.clone()),
follower: Set(follow_activity.actor),
following: Set(activity_model.actor),
..Default::default()
}
).exec(self.db()).await?;
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
if !expanded_addressing.contains(&follow_activity.actor) {
expanded_addressing.push(follow_activity.actor);
}
self.address_to(Some(&activity_model.id), None, &expanded_addressing).await?;
self.address_to(&activity_model.id, None, &activity.addressed()).await?;
Ok(())
}
async fn reject(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
async fn reject(&self, activity: serde_json::Value) -> crate::Result<()> {
// TODO what about TentativeReject?
let activity_model = model::activity::Model::new(&activity)?;
let Some(follow_request_id) = &activity_model.object else {
let Some(follow_request_id) = activity_model.object else {
return Err(UpubError::bad_request());
};
let Some(follow_activity) = model::activity::Entity::find_by_id(follow_request_id)
@ -230,25 +111,14 @@ impl apb::server::Inbox for Context {
if follow_activity.object.unwrap_or("".into()) != activity_model.actor {
return Err(UpubError::forbidden());
}
tracing::info!("{} rejected follow request by {}", activity_model.actor, follow_activity.actor);
model::activity::Entity::insert(activity_model.clone().into_active_model())
.exec(self.db())
.await?;
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
if !expanded_addressing.contains(&follow_activity.actor) {
expanded_addressing.push(follow_activity.actor);
}
self.address_to(Some(&activity_model.id), None, &expanded_addressing).await?;
self.address_to(&activity_model.id, None, &activity.addressed()).await?;
Ok(())
}
async fn delete(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
async fn delete(&self, activity: serde_json::Value) -> crate::Result<()> {
// TODO verify the signature before just deleting lmao
let oid = activity.object().id().ok_or(UpubError::bad_request())?;
tracing::debug!("deleting '{oid}'"); // this is so spammy wtf!
// TODO maybe we should keep the tombstone?
model::user::Entity::delete_by_id(&oid).exec(self.db()).await.info_failed("failed deleting from users");
model::activity::Entity::delete_by_id(&oid).exec(self.db()).await.info_failed("failed deleting from activities");
@ -256,150 +126,41 @@ impl apb::server::Inbox for Context {
Ok(())
}
async fn update(&self, server: String, activity: serde_json::Value) -> crate::Result<()> {
async fn update(&self, activity: serde_json::Value) -> crate::Result<()> {
let activity_model = model::activity::Model::new(&activity)?;
let aid = activity_model.id.clone();
let activity_targets = activity.addressed();
let Some(object_node) = activity.object().extract() else {
// TODO we could process non-embedded activities or arrays but im lazy rn
tracing::error!("refusing to process activity without embedded object: {}", serde_json::to_string_pretty(&activity).unwrap());
return Err(UpubError::unprocessable());
};
let aid = activity_model.id.clone();
let Some(oid) = object_node.id().map(|x| x.to_string()) else {
return Err(UpubError::bad_request());
};
// make sure we're allowed to edit this object
if let Some(object_author) = object_node.attributed_to().id() {
if server != Context::server(&object_author) {
return Err(UpubError::forbidden());
}
} else if server != Context::server(&oid) {
return Err(UpubError::forbidden());
};
model::activity::Entity::insert(activity_model.into_active_model()).exec(self.db()).await?;
match object_node.object_type() {
Some(apb::ObjectType::Actor(_)) => {
// TODO oof here is an example of the weakness of this model, we have to go all the way
// back up to serde_json::Value because impl Object != impl Actor
let actor_model = model::user::Model::new(&object_node)?;
let mut update_model = actor_model.into_active_model();
update_model.updated = sea_orm::Set(chrono::Utc::now());
update_model.reset(model::user::Column::Name);
update_model.reset(model::user::Column::Summary);
update_model.reset(model::user::Column::Image);
update_model.reset(model::user::Column::Icon);
model::user::Entity::update(update_model)
model::user::Entity::update(actor_model.into_active_model())
.exec(self.db()).await?;
},
Some(apb::ObjectType::Note) => {
let object_model = model::object::Model::new(&object_node)?;
let mut update_model = object_model.into_active_model();
update_model.updated = sea_orm::Set(Some(chrono::Utc::now()));
update_model.reset(model::object::Column::Name);
update_model.reset(model::object::Column::Summary);
update_model.reset(model::object::Column::Content);
update_model.reset(model::object::Column::Sensitive);
model::object::Entity::update(update_model)
model::object::Entity::update(object_model.into_active_model())
.exec(self.db()).await?;
},
Some(t) => tracing::warn!("no side effects implemented for update type {t:?}"),
None => tracing::warn!("empty type on embedded updated object"),
}
self.address_to(&aid, Some(&oid), &activity_targets).await?;
tracing::info!("{} updated {}", aid, oid);
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db())
.await?;
let expanded_addressing = self.expand_addressing(activity.addressed()).await?;
self.address_to(Some(&aid), Some(&oid), &expanded_addressing).await?;
Ok(())
}
async fn undo(&self, server: String, activity: serde_json::Value) -> crate::Result<()> {
let uid = activity.actor().id().ok_or_else(UpubError::bad_request)?;
// TODO in theory we could work with just object_id but right now only accept embedded
let undone_activity = activity.object().extract().ok_or_else(UpubError::bad_request)?;
let undone_aid = undone_activity.id().ok_or_else(UpubError::bad_request)?;
let undone_object_id = undone_activity.object().id().ok_or_else(UpubError::bad_request)?;
let activity_type = undone_activity.activity_type().ok_or_else(UpubError::bad_request)?;
let undone_activity_author = undone_activity.actor().id().ok_or_else(UpubError::bad_request)?;
// can't undo activities from remote actors!
if server != Context::server(&undone_activity_author) {
return Err(UpubError::forbidden());
};
match activity_type {
apb::ActivityType::Like => {
model::like::Entity::delete_many()
.filter(
Condition::all()
.add(model::like::Column::Actor.eq(&uid))
.add(model::like::Column::Likes.eq(&undone_object_id))
)
.exec(self.db())
.await?;
model::object::Entity::update_many()
.filter(model::object::Column::Id.eq(&undone_object_id))
.col_expr(model::object::Column::Likes, Expr::col(model::object::Column::Likes).sub(1))
.exec(self.db())
.await?;
},
apb::ActivityType::Follow => {
model::relation::Entity::delete_many()
.filter(
Condition::all()
.add(model::relation::Column::Follower.eq(&uid))
.add(model::relation::Column::Following.eq(&undone_object_id))
)
.exec(self.db())
.await?;
},
_ => {
tracing::error!("received 'Undo' for unimplemented activity: {}", serde_json::to_string_pretty(&activity).unwrap());
return Err(StatusCode::NOT_IMPLEMENTED.into());
},
}
model::activity::Entity::delete_by_id(undone_aid).exec(self.db()).await?;
Ok(())
}
async fn announce(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
let activity_model = model::activity::Model::new(&activity)?;
let Some(oid) = &activity_model.object else {
return Err(FieldError("object").into());
};
self.fetch_object(oid).await?;
// relays send us activities as Announce, but we don't really want to count those towards the
// total shares count of an object, so just fetch the object and be done with it
if self.is_relay(&activity_model.actor) {
tracing::info!("relay {} broadcasted {}", activity_model.actor, oid);
return Ok(())
}
let share = model::share::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
actor: sea_orm::Set(activity_model.actor.clone()),
shares: sea_orm::Set(oid.clone()),
date: sea_orm::Set(activity.published().unwrap_or(chrono::Utc::now())),
};
let expanded_addressing = self.expand_addressing(activity.addressed()).await?;
self.address_to(Some(&activity_model.id), None, &expanded_addressing).await?;
model::share::Entity::insert(share)
.exec(self.db()).await?;
model::activity::Entity::insert(activity_model.clone().into_active_model())
.exec(self.db())
.await?;
model::object::Entity::update_many()
.col_expr(model::object::Column::Shares, Expr::col(model::object::Column::Shares).add(1))
.filter(model::object::Column::Id.eq(oid.clone()))
.exec(self.db())
.await?;
tracing::info!("{} shared {}", activity_model.actor, oid);
Ok(())
async fn undo(&self, _activity: serde_json::Value) -> crate::Result<()> {
todo!()
}
}

View file

@ -1,11 +1,8 @@
pub mod admin;
pub mod context;
pub mod dispatcher;
pub mod fetcher;
pub mod inbox;
pub mod outbox;
pub mod auth;
pub mod builders;
pub mod httpsign;
pub use context::Context;

View file

@ -1,10 +1,9 @@
use apb::{target::Addressed, Activity, ActivityMut, ActorMut, BaseMut, Node, Object, ObjectMut, PublicKeyMut};
use reqwest::StatusCode;
use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use apb::{target::Addressed, Activity, ActivityMut, BaseMut, Node, ObjectMut};
use sea_orm::{EntityTrait, IntoActiveModel, Set};
use crate::{errors::UpubError, model, routes::activitypub::jsonld::LD};
use crate::{errors::UpubError, model};
use super::{fetcher::Fetcher, Context};
use super::Context;
#[axum::async_trait]
@ -14,32 +13,15 @@ impl apb::server::Outbox for Context {
type Activity = serde_json::Value;
async fn create_note(&self, uid: String, object: serde_json::Value) -> crate::Result<String> {
let raw_oid = uuid::Uuid::new_v4().to_string();
let oid = self.oid(raw_oid.clone());
let oid = self.oid(uuid::Uuid::new_v4().to_string());
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let activity_targets = object.addressed();
let mut object_model = model::object::Model::new(
let object_model = model::object::Model::new(
&object
.set_id(Some(&oid))
.set_attributed_to(Node::link(uid.clone()))
.set_published(Some(chrono::Utc::now()))
)?;
if let Some(content) = object_model.content {
object_model.content = Some(mdhtml::safe_markdown(&content));
}
match (&object_model.in_reply_to, &object_model.context) {
(Some(reply_id), None) => // get context from replied object
object_model.context = self.fetch_object(reply_id).await?.context,
(None, None) => // generate a new context
object_model.context = Some(crate::url!(self, "/context/{}", uuid::Uuid::new_v4().to_string())),
(_, Some(_)) => {}, // leave it as set by user
}
let reply_to = object_model.in_reply_to.clone();
if let Some(fe_url) = &self.cfg().instance.frontend {
object_model.url = Some(format!("{fe_url}/objects/{raw_oid}"));
}
let activity_model = model::activity::Model {
id: aid.clone(),
activity_type: apb::ActivityType::Create,
@ -57,18 +39,6 @@ impl apb::server::Outbox for Context {
.exec(self.db()).await?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
model::user::Entity::update_many()
.col_expr(model::user::Column::StatusesCount, Expr::col(model::user::Column::StatusesCount).add(1))
.filter(model::user::Column::Id.eq(&uid))
.exec(self.db())
.await?;
if let Some(reply_to) = reply_to {
model::object::Entity::update_many()
.filter(model::object::Column::Id.eq(reply_to))
.col_expr(model::object::Column::Comments, Expr::col(model::object::Column::Comments).add(1))
.exec(self.db())
.await?;
}
self.dispatch(&uid, activity_targets, &aid, Some(&oid)).await?;
@ -80,8 +50,7 @@ impl apb::server::Outbox for Context {
return Err(UpubError::bad_request());
};
let raw_oid = uuid::Uuid::new_v4().to_string();
let oid = self.oid(raw_oid.clone());
let oid = self.oid(uuid::Uuid::new_v4().to_string());
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let activity_targets = activity.addressed();
let mut object_model = model::object::Model::new(
@ -96,42 +65,16 @@ impl apb::server::Outbox for Context {
.set_actor(Node::link(uid.clone()))
.set_published(Some(chrono::Utc::now()))
)?;
activity_model.object = Some(oid.clone());
object_model.to = activity_model.to.clone();
object_model.bto = activity_model.bto.clone();
object_model.cc = activity_model.cc.clone();
object_model.bcc = activity_model.bcc.clone();
if let Some(content) = object_model.content {
object_model.content = Some(mdhtml::safe_markdown(&content));
}
match (&object_model.in_reply_to, &object_model.context) {
(Some(reply_id), None) => // get context from replied object
object_model.context = self.fetch_object(reply_id).await?.context,
(None, None) => // generate a new context
object_model.context = Some(crate::url!(self, "/context/{}", uuid::Uuid::new_v4().to_string())),
(_, Some(_)) => {}, // leave it as set by user
}
let reply_to = object_model.in_reply_to.clone();
if let Some(fe_url) = &self.cfg().instance.frontend {
object_model.url = Some(format!("{fe_url}/objects/{raw_oid}"));
}
activity_model.object = Some(oid.clone());
model::object::Entity::insert(object_model.into_active_model())
.exec(self.db()).await?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
model::user::Entity::update_many()
.col_expr(model::user::Column::StatusesCount, Expr::col(model::user::Column::StatusesCount).add(1))
.filter(model::user::Column::Id.eq(&uid))
.exec(self.db())
.await?;
if let Some(reply_to) = reply_to {
model::object::Entity::update_many()
.filter(model::object::Column::Id.eq(reply_to))
.col_expr(model::object::Column::Comments, Expr::col(model::object::Column::Comments).add(1))
.exec(self.db())
.await?;
}
self.dispatch(&uid, activity_targets, &aid, Some(&oid)).await?;
@ -142,8 +85,9 @@ impl apb::server::Outbox for Context {
async fn like(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let activity_targets = activity.addressed();
let oid = activity.object().id().ok_or_else(UpubError::bad_request)?;
self.fetch_object(&oid).await?;
let Some(oid) = activity.object().id() else {
return Err(UpubError::bad_request());
};
let activity_model = model::activity::Model::new(
&activity
.set_id(Some(&aid))
@ -153,18 +97,13 @@ impl apb::server::Outbox for Context {
let like_model = model::like::ActiveModel {
actor: Set(uid.clone()),
likes: Set(oid.clone()),
likes: Set(oid),
date: Set(chrono::Utc::now()),
..Default::default()
};
model::like::Entity::insert(like_model).exec(self.db()).await?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
model::object::Entity::update_many()
.col_expr(model::object::Column::Likes, Expr::col(model::object::Column::Likes).add(1))
.filter(model::object::Column::Id.eq(oid))
.exec(self.db())
.await?;
self.dispatch(&uid, activity_targets, &aid, None).await?;
@ -209,14 +148,6 @@ impl apb::server::Outbox for Context {
match accepted_activity.activity_type {
apb::ActivityType::Follow => {
model::user::Entity::update_many()
.col_expr(
model::user::Column::FollowersCount,
Expr::col(model::user::Column::FollowersCount).add(1)
)
.filter(model::user::Column::Id.eq(&uid))
.exec(self.db())
.await?;
model::relation::Entity::insert(
model::relation::ActiveModel {
follower: Set(accepted_activity.actor), following: Set(uid.clone()),
@ -248,31 +179,34 @@ impl apb::server::Outbox for Context {
async fn undo(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let activity_targets = activity.addressed();
let old_aid = activity.object().id().ok_or_else(UpubError::bad_request)?;
let old_activity = model::activity::Entity::find_by_id(old_aid)
.one(self.db())
.await?
.ok_or_else(UpubError::not_found)?;
{
let Some(old_aid) = activity.object().id() else {
return Err(UpubError::bad_request());
};
let Some(old_activity) = model::activity::Entity::find_by_id(old_aid)
.one(self.db()).await?
else {
return Err(UpubError::not_found());
};
if old_activity.actor != uid {
return Err(UpubError::forbidden());
}
match old_activity.activity_type {
apb::ActivityType::Like => {
model::like::Entity::delete_many()
.filter(model::like::Column::Actor.eq(old_activity.actor))
.filter(model::like::Column::Likes.eq(old_activity.object.unwrap_or("".into())))
.exec(self.db())
.await?;
model::like::Entity::delete(model::like::ActiveModel {
actor: Set(old_activity.actor), likes: Set(old_activity.object.unwrap_or("".into())),
..Default::default()
}).exec(self.db()).await?;
},
apb::ActivityType::Follow => {
model::relation::Entity::delete_many()
.filter(model::relation::Column::Follower.eq(old_activity.actor))
.filter(model::relation::Column::Following.eq(old_activity.object.unwrap_or("".into())))
.exec(self.db())
.await?;
model::relation::Entity::delete(model::relation::ActiveModel {
follower: Set(old_activity.actor), following: Set(old_activity.object.unwrap_or("".into())),
..Default::default()
}).exec(self.db()).await?;
},
t => tracing::warn!("extra side effects for activity {t:?} not implemented"),
}
}
let activity_model = model::activity::Model::new(
&activity
.set_id(Some(&aid))
@ -287,165 +221,4 @@ impl apb::server::Outbox for Context {
Ok(aid)
}
async fn delete(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let oid = activity.object().id().ok_or_else(UpubError::bad_request)?;
let object = model::object::Entity::find_by_id(&oid)
.one(self.db())
.await?
.ok_or_else(UpubError::not_found)?;
let Some(author_id) = object.attributed_to else {
// can't change local objects attributed to nobody
return Err(UpubError::forbidden())
};
if author_id != uid {
// can't change objects of others
return Err(UpubError::forbidden());
}
let addressed = activity.addressed();
let activity_model = model::activity::Model::new(
&activity
.set_id(Some(&aid))
.set_actor(Node::link(uid.clone()))
.set_published(Some(chrono::Utc::now()))
)?;
model::object::Entity::delete_by_id(&oid)
.exec(self.db())
.await?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db())
.await?;
self.dispatch(&uid, addressed, &aid, None).await?;
Ok(aid)
}
async fn update(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let object_node = activity.object().extract().ok_or_else(UpubError::bad_request)?;
match object_node.object_type() {
Some(apb::ObjectType::Actor(_)) => {
let mut actor_model = model::user::Model::new(
&object_node
// TODO must set these, but we will ignore them
.set_actor_type(Some(apb::ActorType::Person))
.set_public_key(apb::Node::object(
serde_json::Value::new_object().set_public_key_pem("")
))
)?;
let old_actor_model = model::user::Entity::find_by_id(&actor_model.id)
.one(self.db())
.await?
.ok_or_else(UpubError::not_found)?;
if old_actor_model.id != uid {
// can't change user fields of others
return Err(UpubError::forbidden());
}
if actor_model.name.is_none() { actor_model.name = old_actor_model.name }
if actor_model.summary.is_none() { actor_model.summary = old_actor_model.summary }
if actor_model.image.is_none() { actor_model.image = old_actor_model.image }
if actor_model.icon.is_none() { actor_model.icon = old_actor_model.icon }
let mut update_model = actor_model.into_active_model();
update_model.updated = sea_orm::Set(chrono::Utc::now());
update_model.reset(model::user::Column::Name);
update_model.reset(model::user::Column::Summary);
update_model.reset(model::user::Column::Image);
update_model.reset(model::user::Column::Icon);
model::user::Entity::update(update_model)
.exec(self.db()).await?;
},
Some(apb::ObjectType::Note) => {
let mut object_model = model::object::Model::new(
&object_node.set_published(Some(chrono::Utc::now()))
)?;
let old_object_model = model::object::Entity::find_by_id(&object_model.id)
.one(self.db())
.await?
.ok_or_else(UpubError::not_found)?;
// can't change local objects attributed to nobody
let author_id = old_object_model.attributed_to.ok_or_else(UpubError::forbidden)?;
if author_id != uid {
// can't change objects of others
return Err(UpubError::forbidden());
}
if object_model.name.is_none() { object_model.name = old_object_model.name }
if object_model.summary.is_none() { object_model.summary = old_object_model.summary }
if object_model.content.is_none() { object_model.content = old_object_model.content }
let mut update_model = object_model.into_active_model();
update_model.updated = sea_orm::Set(Some(chrono::Utc::now()));
update_model.reset(model::object::Column::Name);
update_model.reset(model::object::Column::Summary);
update_model.reset(model::object::Column::Content);
update_model.reset(model::object::Column::Sensitive);
model::object::Entity::update(update_model)
.exec(self.db()).await?;
},
_ => return Err(UpubError::Status(StatusCode::NOT_IMPLEMENTED)),
}
let addressed = activity.addressed();
let activity_model = model::activity::Model::new(
&activity
.set_id(Some(&aid))
.set_actor(Node::link(uid.clone()))
.set_published(Some(chrono::Utc::now()))
)?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
self.dispatch(&uid, addressed, &aid, None).await?;
Ok(aid)
}
async fn announce(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
let aid = self.aid(uuid::Uuid::new_v4().to_string());
let activity_targets = activity.addressed();
let oid = activity.object().id().ok_or_else(UpubError::bad_request)?;
self.fetch_object(&oid).await?;
let activity_model = model::activity::Model::new(
&activity
.set_id(Some(&aid))
.set_published(Some(chrono::Utc::now()))
.set_actor(Node::link(uid.clone()))
)?;
let share_model = model::share::ActiveModel {
actor: Set(uid.clone()),
shares: Set(oid.clone()),
date: Set(chrono::Utc::now()),
..Default::default()
};
model::share::Entity::insert(share_model).exec(self.db()).await?;
model::activity::Entity::insert(activity_model.into_active_model())
.exec(self.db()).await?;
model::object::Entity::update_many()
.col_expr(model::object::Column::Shares, Expr::col(model::object::Column::Shares).add(1))
.filter(model::object::Column::Id.eq(oid))
.exec(self.db())
.await?;
self.dispatch(&uid, activity_targets, &aid, None).await?;
Ok(aid)
}
}

41
src/tools.rs Normal file
View file

@ -0,0 +1,41 @@
// yanked from https://github.com/SeaQL/sea-orm/discussions/1502
use sea_orm::{prelude::*, FromQueryResult};
use sea_orm::sea_query::{Alias, IntoIden, SelectExpr, SelectStatement};
use sea_orm::{EntityTrait, QueryTrait};
pub struct Prefixer<S: QueryTrait<QueryStatement = SelectStatement>> {
pub selector: S,
}
impl<S: QueryTrait<QueryStatement = SelectStatement>> Prefixer<S> {
pub fn new(selector: S) -> Self {
Self { selector }
}
pub fn add_columns<T: EntityTrait>(mut self, entity: T) -> Self {
for col in <T::Column as sea_orm::entity::Iterable>::iter() {
let alias = format!("{}{}", entity.table_name(), col.to_string()); // we use entity.table_name() as prefix
self.selector.query().expr(SelectExpr {
expr: col.select_as(col.into_expr()),
alias: Some(Alias::new(&alias).into_iden()),
window: None,
});
}
self
}
}
// adapted from https://github.com/SeaQL/sea-orm/discussions/1502
#[derive(Debug)]
pub struct ActivityWithObject {
pub activity: crate::model::activity::Model,
pub object: Option<crate::model::object::Model>,
}
impl FromQueryResult for ActivityWithObject {
fn from_query_result(res: &sea_orm::QueryResult, _pre: &str) -> Result<Self, sea_orm::DbErr> {
let activity = crate::model::activity::Model::from_query_result(res, crate::model::activity::Entity.table_name())?;
let object = crate::model::object::Model::from_query_result(res, crate::model::object::Entity.table_name()).ok();
Ok(Self { activity, object })
}
}

View file

@ -1,34 +0,0 @@
[package]
name = "upub-web"
version = "0.1.0"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "web frontend for upub"
license = "AGPL-3.0"
keywords = ["activitypub", "upub", "json", "web", "wasm"]
repository = "https://git.alemi.dev/upub.git"
#readme = "README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
log = "0.4"
tracing = "0.1"
console_log = "1.0"
console_error_panic_hook = "0.1"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_default = "0.1"
serde-inline-default = "0.2"
dashmap = "5.5"
leptos = { version = "0.6", features = ["csr", "tracing"] }
leptos_router = { version = "0.6", features = ["csr"] }
leptos-use = { version = "0.10", features = ["serde"] }
reqwest = { version = "0.12", features = ["json"] }
apb = { path = "../apb", features = ["unstructured", "activitypub-fe", "activitypub-counters", "litepub"] }
futures = "0.3.30"
lazy_static = "1.4"
chrono = { version = "0.4", features = ["serde"] }
web-sys = { version = "0.3", features = ["Screen"] }
mdhtml = { path = "../mdhtml/" }

View file

@ -1,301 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>upub</title>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:type" content="website" />
<meta property="og:title" content="upub">
<meta property="og:description" content="micro social network, federated" />
<meta property="og:url" content="https://feditest.alemi.dev/web" />
<meta property="og:site_name" content="upub" />
<link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link crossorigin rel="stylesheet" href="https://cdn.alemi.dev/web/alemi.css">
<style>
:root {
--main-col-percentage: 75%;
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff") format("woff");
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff") format("woff");
}
* {
font-family: 'Fira Code', Menlo, DejaVu Sans Mono, Monaco, Consolas, Ubuntu Mono, monospace;
}
body {
margin: 0;
padding-bottom: 1.2em;
font-size: 11pt;
}
textarea {
font-size: 10pt;
}
nav {
z-index: 90;
top: 0;
position: sticky;
padding-top: .05em;
background-color: var(--background);
}
footer {
width: 100%;
position: fixed;
bottom: 0;
background-color: var(--background);
text-align: center;
padding-bottom: 0;
line-height: 1rem;
}
main {
margin: 0em 1em;
}
blockquote {
margin-top: .5em;
margin-bottom: .5em;
margin-left: 1.25em;
padding-left: .3em;
overflow-wrap: break-word;
hyphens: auto;
border-left: solid 3px var(--background-secondary);
}
blockquote.tl {
color: var(--text);
border-left: solid 3px var(--accent);
margin-top: 0;
margin-bottom: 0;
}
blockquote.tl p {
margin: 0 .5em;
}
span.footer {
padding: .1em;
font-size: .6em;
color: var(--secondary);
}
span.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
hr.sep {
border: 1px solid #bf616a70;
}
hr.sticky {
position: sticky;
z-index: 100;
margin-top: 0;
padding-top: 0;
margin-bottom: 0;
padding-bottom: 0;
top: 1.65rem;
}
div.sticky {
z-index: 100;
top: 2rem;
position: sticky;
background-color: var(--background);
}
div.border {
padding: 1em;
border: 1px dashed var(--accent);
}
@media screen and (max-width: 786px) {
div.sticky {
top: 1.75rem;
padding-top: .25rem;
}
}
a.upub-title {
color: var(--primary);
text-decoration: none;
}
a.upub-title:hover {
text-decoration: underline;
}
a.hover {
text-decoration: none;
}
a.hover:hover {
text-decoration: underline;
}
a.breadcrumb {
text-decoration: none;
color: var(--secondary);
}
a.breadcrumb:hover {
font-weight: bold;
color: var(--primary);
}
b.big {
font-size: 18pt;
}
div.banner {
margin-top: .3em;
outline: .3em solid #bf616a55;
}
div.overlap {
position: relative;
bottom: 3em;
}
img {
max-width: 100%;
}
img.avatar-circle {
display: inline;
border-radius: 50%;
}
img.avatar-border {
background-color: var(--background);
border: .3em solid #BF616A;
}
img.avatar-inline {
display: inline;
height: .75em;
border-radius: 50%;
}
img.inline-avatar {
height: 2em;
width: 2em;
}
.box {
border: 3px solid #bf616a;
}
.cursor {
cursor: pointer;
}
video.attachment {
height: 10em;
}
img.attachment {
cursor: pointer;
height: 10em;
width: 100%;
border: 3px solid #bf616a;
padding: 5px;
object-fit: cover;
box-sizing: border-box;
}
img.expand,
video.expand {
height: unset;
max-height: 55vh;
max-width: 100%;
object-fit: contain;
}
div.tl-header {
background-color: #bf616a55;
color: #bf616a;
}
p.tiny-text {
line-height: .75em;
}
table.post-table {
border-collapse: collapse;
}
table p {
margin: .25em 1em;
}
tr.post-table,
td.post-table {
border: 1px dashed #bf616a;
padding: .5em;
}
td.top {
vertical-align: top;
}
td.bottom {
vertical-align: bottom;
}
details>summary::marker {
display: none;
}
details>summary {
list-style: none;
cursor: pointer;
}
details>summary:hover {
font-weight: bold;
}
code.cw {
display: block;
}
input[type=button]:hover,
input[type=submit].active {
background-color: var(--accent);
border-color: var(--accent);
color: var(--background);
cursor: pointer;
}
.ml-1-r {
margin-left: 1em;
}
.depth-r {
margin-left: .5em;
}
.only-on-mobile {
display: none;
}
@media screen and (max-width: 786px) {
.depth-r {
margin-left: .125em;
}
.ml-1-r {
margin-left: 0;
}
.only-on-mobile {
display: inherit;
}
.hidden-on-mobile {
display: none;
}
div.col-side {
padding-right: .25em;
}
main {
margin: 0;
}
}
@media screen and (max-width: 400px) {
.hidden-on-tiny {
display: none;
}
}
span.emoji {
color: transparent;
text-shadow: 0 0 0 var(--secondary);
}
span.emoji-btn:hover {
color: unset;
text-shadow: unset;
}
div.context {
border-left: 1px solid var(--background-dim);
padding-left: 1px;
}
</style>
</head>
</head>
<body>
</body>
</html>

View file

@ -1,140 +0,0 @@
use leptos::*;
use leptos_router::*;
use crate::prelude::*;
use leptos_use::{storage::use_local_storage, use_cookie, use_cookie_with_options, utils::{FromToStringCodec, JsonCodec}, UseCookieOptions};
#[component]
pub fn App() -> impl IntoView {
let (token, set_token) = use_cookie_with_options::<String, FromToStringCodec>(
"token",
UseCookieOptions::default()
.max_age(1000 * 60 * 60 * 6)
);
let (config, set_config, _) = use_local_storage::<crate::Config, JsonCodec>("config");
let (userid, set_userid) = use_cookie::<String, FromToStringCodec>("user_id");
let auth = Auth { token, userid };
provide_context(auth);
provide_context(config);
let username = auth.userid.get_untracked()
.map(|x| x.split('/').last().unwrap_or_default().to_string())
.unwrap_or_default();
let home_tl = Timeline::new(format!("{URL_BASE}/users/{username}/inbox/page"));
let server_tl = Timeline::new(format!("{URL_BASE}/inbox/page"));
let user_tl = Timeline::new(format!("{URL_BASE}/users/{username}/outbox/page"));
let context_tl = Timeline::new(format!("{URL_BASE}/outbox/page"));
let reply_controls = ReplyControls::default();
provide_context(reply_controls);
let screen_width = window().screen().map(|x| x.avail_width().unwrap_or_default()).unwrap_or_default();
let (menu, set_menu) = create_signal(screen_width <= 786);
let (advanced, set_advanced) = create_signal(false);
spawn_local(async move {
if let Err(e) = server_tl.more(auth).await {
tracing::error!("error populating timeline: {e}");
}
});
let auth_present = auth.token.get_untracked().is_some(); // skip helper to use get_untracked
if auth_present {
spawn_local(async move {
if let Err(e) = home_tl.more(auth).await {
tracing::error!("error populating timeline: {e}");
}
});
}
let title_target = if auth_present { "/web/home" } else { "/web/server" };
view! {
<nav class="w-100 mt-1 mb-1 pb-s">
<code class="color ml-3" ><a class="upub-title" href={title_target} >μpub</a></code>
<small class="ml-1 mr-1 hidden-on-tiny" ><a class="clean" href="/web/server" >micro social network, federated</a></small>
/* TODO kinda jank with the float but whatever, will do for now */
<input type="submit" class="mr-2 rev" on:click=move |_| set_menu.set(!menu.get()) value="menu" style="float: right" />
</nav>
<hr class="sep sticky" />
<div class="container mt-2 pt-2" >
<div class="two-col" >
<div class="col-side sticky pb-s" class:hidden=move || menu.get() >
<Navigator />
<hr class="mt-1 mb-1" />
<LoginBox
token_tx=set_token
userid_tx=set_userid
home_tl=home_tl
server_tl=server_tl
/>
<hr class="mt-1 mb-1" />
<div class:hidden=move || !auth.present() >
{move || if advanced.get() { view! {
<AdvancedPostBox advanced=set_advanced/>
}} else { view! {
<PostBox advanced=set_advanced/>
}}}
<hr class="only-on-mobile sep mb-0 pb-0" />
</div>
</div>
<div class="col-main" class:w-100=move || menu.get() >
<Router // TODO maybe set base="/web" ?
trailing_slash=TrailingSlash::Redirect
fallback=move || view! {
<Breadcrumb back=true >404</Breadcrumb>
<div class="center">
<h3>nothing to see here!</h3>
<p><a href="/web"><button type="button">back to root</button></a></p>
</div>
}.into_view()
>
// TODO this is kind of ugly: the whole router gets rebuilt every time we log in/out
// in a sense it's what we want: refreshing the home tl is main purpose, but also
// server tl may contain stuff we can no longer see, or otherwise we may now be
// entitled to see new posts. so while being ugly it's techically correct ig?
{move || {
view! {
<main>
<Routes>
<Route path="/web" view=move ||
if auth.present() {
view! { <Redirect path="/web/home" /> }
} else {
view! { <Redirect path="/web/server" /> }
}
/>
<Route path="/web/home" view=move || view! { <TimelinePage name="home" tl=home_tl /> } />
<Route path="/web/server" view=move || view! { <TimelinePage name="server" tl=server_tl /> } />
<Route path="/web/about" view=AboutPage />
<Route path="/web/config" view=move || view! { <ConfigPage setter=set_config /> } />
<Route path="/web/config/dev" view=DebugPage />
<Route path="/web/users/:id" view=move || view! { <UserPage tl=user_tl /> } />
<Route path="/web/objects/:id" view=move || view! { <ObjectPage tl=context_tl /> } />
<Route path="/web/search" view=SearchPage />
<Route path="/web/register" view=RegisterPage />
<Route path="/" view=move || view! { <Redirect path="/web" /> } />
</Routes>
</main>
}
}}
</Router>
</div>
</div>
</div>
<footer>
<div>
<hr class="sep" />
<span class="footer" >"\u{26fc} woven under moonlight :: "<a href="https://git.alemi.dev/upub.git" target="_blank" >src</a>" :: "<a href="javascript:window.scrollTo({top:0, behavior:'smooth'})">top</a></span>
</div>
</footer>
}
}

View file

@ -1,44 +0,0 @@
use leptos::*;
use crate::URL_BASE;
pub trait AuthToken {
fn present(&self) -> bool;
fn token(&self) -> String;
fn user_id(&self) -> String;
fn username(&self) -> String;
fn outbox(&self) -> String;
}
#[derive(Debug, Clone, Copy)]
pub struct Auth {
pub token: Signal<Option<String>>,
pub userid: Signal<Option<String>>,
}
impl AuthToken for Auth {
fn token(&self) -> String {
self.token.get().unwrap_or_default()
}
fn user_id(&self) -> String {
self.userid.get().unwrap_or_default()
}
fn username(&self) -> String {
// TODO maybe cache this?? how often do i need it?
self.userid.get()
.unwrap_or_default()
.split('/')
.last()
.unwrap_or_default()
.to_string()
}
fn present(&self) -> bool {
self.token.get().map_or(false, |x| !x.is_empty())
}
fn outbox(&self) -> String {
format!("{URL_BASE}/users/{}/outbox", self.username())
}
}

View file

@ -1,84 +0,0 @@
use leptos::*;
use crate::prelude::*;
use apb::{target::Addressed, Base, Activity, Object};
#[component]
pub fn ActivityLine(activity: crate::Object) -> impl IntoView {
let object_id = activity.object().id().unwrap_or_default();
let actor_id = activity.actor().id().unwrap_or_default();
let actor = CACHE.get_or(&actor_id, serde_json::Value::String(actor_id.clone()).into());
let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity);
let href = match kind {
apb::ActivityType::Follow => Uri::web(FetchKind::User, &object_id),
// TODO for update check what's being updated
_ => Uri::web(FetchKind::Object, &object_id),
};
view! {
<div>
<span class="ml-1-r">
<ActorStrip object=actor />
</span>
<span style="float:right">
<code class="color moreinfo" title={activity.published().map(|x| x.to_rfc2822())} >
<a class="upub-title clean" title={object_id} href={href} >
{kind.as_ref().to_string()}
</a>
<PrivacyMarker addressed=activity.addressed() />
</code>
</span>
</div>
}
}
#[component]
pub fn Item(
item: crate::Object,
#[prop(optional)] sep: bool,
) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
let id = item.id().unwrap_or_default().to_string();
let sep = if sep { Some(view! { <hr /> }) } else { None };
match item.object_type() {
// special case for placeholder activities
Some(apb::ObjectType::Note) | Some(apb::ObjectType::Document(_)) => (move || {
if config.get().filters.orphans {
Some(view! { <Object object=item.clone() />{sep.clone()} })
} else {
None
}
}).into_view(),
// everything else
Some(apb::ObjectType::Activity(t)) => (move || {
if config.get().filters.visible(apb::ObjectType::Activity(t)) {
let object_id = item.object().id().unwrap_or_default();
let object = match t {
apb::ActivityType::Create | apb::ActivityType::Announce =>
CACHE.get(&object_id).map(|obj| {
view! { <Object object=obj /> }
}.into_view()),
apb::ActivityType::Follow =>
CACHE.get(&object_id).map(|obj| {
view! {
<div class="ml-1">
<ActorBanner object=obj />
<FollowRequestButtons activity_id=id.clone() actor_id=object_id />
</div>
}
}.into_view()),
_ => None,
};
Some(view! {
<ActivityLine activity=item.clone() />
{object}
{sep.clone()}
})
} else {
None
}
}).into_view(),
// should never happen
_ => view! { <p><code>type not implemented</code></p> }.into_view(),
}
}

View file

@ -1,98 +0,0 @@
use leptos::*;
use crate::prelude::*;
#[component]
pub fn LoginBox(
token_tx: WriteSignal<Option<String>>,
userid_tx: WriteSignal<Option<String>>,
home_tl: Timeline,
server_tl: Timeline,
) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let username_ref: NodeRef<html::Input> = create_node_ref();
let password_ref: NodeRef<html::Input> = create_node_ref();
view! {
<div>
<div class="w-100" class:hidden=move || !auth.present() >
"hi "<a href={move || Uri::web(FetchKind::User, &auth.username() )} >{move || auth.username() }</a>
<input style="float:right" type="submit" value="logout" on:click=move |_| {
token_tx.set(None);
home_tl.reset(format!("{URL_BASE}/outbox/page"));
server_tl.reset(format!("{URL_BASE}/inbox/page"));
spawn_local(async move {
if let Err(e) = server_tl.more(auth).await {
logging::error!("failed refreshing server timeline: {e}");
}
});
} />
</div>
<div class:hidden=move || auth.present() >
<form on:submit=move|ev| {
ev.prevent_default();
logging::log!("logging in...");
let email = username_ref.get().map(|x| x.value()).unwrap_or("".into());
let password = password_ref.get().map(|x| x.value()).unwrap_or("".into());
spawn_local(async move {
let Ok(res) = reqwest::Client::new()
.post(format!("{URL_BASE}/auth"))
.json(&LoginForm { email, password })
.send()
.await
else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
let Ok(auth_response) = res
.json::<AuthResponse>()
.await
else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
logging::log!("logged in until {}", auth_response.expires);
// update our username and token cookies
let username = auth_response.user.split('/').last().unwrap_or_default().to_string();
userid_tx.set(Some(auth_response.user));
token_tx.set(Some(auth_response.token));
// reset home feed and point it to our user's inbox
home_tl.reset(format!("{URL_BASE}/users/{}/inbox/page", username));
spawn_local(async move {
if let Err(e) = home_tl.more(auth).await {
tracing::error!("failed refreshing home timeline: {e}");
}
});
// reset server feed: there may be more content now that we're authed
server_tl.reset(format!("{URL_BASE}/inbox/page"));
spawn_local(async move {
if let Err(e) = server_tl.more(auth).await {
tracing::error!("failed refreshing server timeline: {e}");
}
});
});
} >
<table class="w-100 align">
<tr>
<td colspan="2"><input class="w-100" type="text" node_ref=username_ref placeholder="username" /></td>
</tr>
<tr>
<td colspan="2"><input class="w-100" type="password" node_ref=password_ref placeholder="password" /></td>
</tr>
<tr>
<td class="w-50"><input class="w-100" type="submit" value="login" /></td>
<td class="w-50"><a href="/web/register"><input class="w-100" type="button" value="register" /></a></td>
</tr>
</table>
</form>
</div>
</div>
}
}
#[derive(Debug, serde::Serialize)]
struct LoginForm {
email: String,
password: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct AuthResponse {
token: String,
user: String,
expires: chrono::DateTime<chrono::Utc>,
}

View file

@ -1,66 +0,0 @@
mod login;
pub use login::*;
mod activity;
pub use activity::*;
mod navigation;
pub use navigation::*;
mod object;
pub use object::*;
mod user;
pub use user::*;
mod post;
pub use post::*;
mod timeline;
pub use timeline::*;
use leptos::*;
#[component]
pub fn DateTime(t: Option<chrono::DateTime<chrono::Utc>>) -> impl IntoView {
match t {
Some(t) => {
let delta = chrono::Utc::now() - t;
let pretty = if delta.num_seconds() < 60 {
format!("{}s ago", delta.num_seconds())
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else if delta.num_days() < 90 {
format!("{}d ago", delta.num_days())
} else {
t.format("%d/%m/%Y").to_string()
};
let rfc = t.to_rfc2822();
Some(view! {
<small title={rfc}>{pretty}</small>
})
},
None => None,
}
}
pub const PRIVACY_PUBLIC : &str = "🪩";
pub const PRIVACY_FOLLOWERS : &str = "🔒";
pub const PRIVACY_PRIVATE : &str = "📨";
#[component]
pub fn PrivacyMarker(addressed: Vec<String>) -> impl IntoView {
let privacy = if addressed.iter().any(|x| x == apb::target::PUBLIC) {
PRIVACY_PUBLIC
} else if addressed.iter().any(|x| x.ends_with("/followers")) {
PRIVACY_FOLLOWERS
} else {
PRIVACY_PRIVATE
};
let audience = format!("[ {} ]", addressed.join(", "));
view! {
<span class="emoji ml-1 mr-s moreinfo" title={audience} >{privacy}</span>
}
}

View file

@ -1,48 +0,0 @@
use leptos::*;
use crate::prelude::*;
#[component]
pub fn Breadcrumb(
#[prop(optional)]
back: bool,
children: Children,
) -> impl IntoView {
view! {
<div class="tl-header w-100 center" >
{if back { Some(view! {
<a class="breadcrumb mr-1" href="javascript:history.back()" ><b>"<<"</b></a>
})} else { None }}
<b>{crate::NAME}</b>" :: "{children()}
</div>
}
}
#[component]
pub fn Navigator() -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let (query, set_query) = create_signal("".to_string());
view! {
<form action={move|| format!("/web/search?q={}", query.get())}>
<table class="align">
<tr>
<td class="w-100">
<input type="text" placeholder="search" class="w-100" on:input=move |ev| {
set_query.set(event_target_value(&ev))
} />
</td>
<td>
<a href={move|| format!("/web/search?q={}", query.get())}><input type="submit" value="go" /></a>
</td>
</tr>
</table>
</form>
<table class="align w-100">
<tr><td colspan="2"><a href="/web/home"><input class="w-100" type="submit" class:hidden=move || !auth.present() value="home timeline" /></a></td></tr>
<tr><td colspan="2"><a href="/web/server"><input class="w-100" type="submit" value="server timeline" /></a></td></tr>
<tr>
<td class="w-50"><a href="/web/about"><input class="w-100" type="submit" value="about" /></a></td>
<td class="w-50"><a href="/web/config"><input class="w-100" type="submit" value="config" /></a></td>
</tr>
</table>
}
}

View file

@ -1,334 +0,0 @@
use std::sync::Arc;
use leptos::*;
use crate::{prelude::*, URL_SENSITIVE};
use apb::{target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut};
#[component]
pub fn Attachment(
object: serde_json::Value,
#[prop(optional)]
sensitive: bool
) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
let (expand, set_expand) = create_signal(false);
let href = object.url().id().unwrap_or_default();
let media_type = object.media_type()
.unwrap_or("link") // TODO make it an Option rather than defaulting to link everywhere
.to_string();
let mut kind = media_type
.split('/')
.next()
.unwrap_or("link")
.to_string();
// TODO in theory we should match on document_type, but mastodon and misskey send all attachments
// as "Documents" regardless of type, so we're forced to ignore the actual AP type and just match
// using media_type, uffff
//
// those who correctly send Image type objects without a media type get shown as links here, this
// is a dirty fix to properly display as images
if kind == "link" && matches!(object.document_type(), Some(apb::DocumentType::Image)) {
kind = "image".to_string();
}
match kind.as_str() {
"image" =>
view! {
<p class="center">
<img
class="attachment"
class:expand=expand
src={move || if sensitive && !expand.get() {
URL_SENSITIVE.to_string()
} else {
href.clone()
}}
title={object.name().unwrap_or_default().to_string()}
on:click=move |_| set_expand.set(!expand.get())
/>
</p>
}.into_view(),
"video" => {
let _href = href.clone();
view! {
<div class="center cursor box ml-1"
on:click=move |_| set_expand.set(!expand.get())
title={object.name().unwrap_or_default().to_string()}
>
<video controls class="attachment" class:expand=expand prop:loop=move || config.get().loop_videos >
{move || if sensitive && !expand.get() { None } else { Some(view! { <source src={_href.clone()} type={media_type.clone()} /> }) }}
<a href={href.clone()} target="_blank">video clip</a>
</video>
</div>
}.into_view()
},
"audio" =>
view! {
<p class="center">
<audio controls class="w-100" prop:loop=move || config.get().loop_videos >
<source src={href.clone()} type={media_type} />
<a href={href} target="_blank">audio clip</a>
</audio>
</p>
}.into_view(),
"link" =>
view! {
<code class="cw color center">
<a href={href.clone()} title={href.clone()} rel="noreferrer nofollow" target="_blank">
{Uri::pretty(&href)}
</a>
</code>
{object.name().map(|name| {
view! {
<p class="center mt-0"><small>{name.to_string()}</small></p>
}
})}
}.into_view(),
_ =>
view! {
<p class="center box">
<code class="cw color center">
<a href={href} target="_blank">{media_type}</a>
</code>
{object.name().map(|name| {
view! { <p class="tiny-text"><small>{name.to_string()}</small></p> }
})}
</p>
}.into_view(),
}
}
#[component]
pub fn Object(object: crate::Object) -> impl IntoView {
let oid = object.id().unwrap_or_default().to_string();
let content = mdhtml::safe_html(object.content().unwrap_or_default());
let author_id = object.attributed_to().id().unwrap_or_default();
let author = CACHE.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
let sensitive = object.sensitive().unwrap_or_default();
let addressed = object.addressed();
let public = addressed.iter().any(|x| x.as_str() == apb::target::PUBLIC);
let external_url = object.url().id().unwrap_or_else(|| oid.clone());
let attachments = object.attachment()
.map(|x| view! { <Attachment object=x sensitive=sensitive /> })
.collect_view();
let comments = object.replies().get()
.map_or(0, |x| x.total_items().unwrap_or(0));
let shares = object.shares().get()
.map_or(0, |x| x.total_items().unwrap_or(0));
let likes = object.likes().get()
.map_or(0, |x| x.total_items().unwrap_or(0));
let already_liked = object.liked_by_me().unwrap_or(false);
let attachments_padding = if object.attachment().is_empty() {
None
} else {
Some(view! { <div class="pb-1"></div> })
};
let post_inner = view! {
<Summary summary=object.summary().map(|x| x.to_string()) >
<p inner_html={content}></p>
{attachments_padding}
{attachments}
</Summary>
};
let post = match object.object_type() {
// mastodon, pleroma, misskey
Some(apb::ObjectType::Note) => view! {
<blockquote class="tl">{post_inner}</blockquote>
}.into_view(),
// lemmy with Page, peertube with Video
Some(apb::ObjectType::Document(t)) => view! {
<div class="border ml-1 mr-1 mt-1">
<b>{object.name().unwrap_or_default().to_string()}</b>
<hr />
{post_inner}
<a class="clean color" rel="nofollow noreferrer" href={oid.clone()} target="_blank">
<input class="w-100" type="button" value={t.as_ref().to_string()} />
</a>
</div>
}.into_view(),
// wordpress, ... ?
Some(apb::ObjectType::Article) => view! {
<div>
<h3>{object.name().unwrap_or_default().to_string()}</h3>
<hr />
{post_inner}
</div>
}.into_view(),
// everything else
Some(t) => view! {
<h3>{t.as_ref().to_string()}</h3>
{post_inner}
}.into_view(),
// object without type?
None => view! { <code>missing object type</code> }.into_view(),
};
view! {
<table class="align w-100 ml-s mr-s">
<tr>
<td><ActorBanner object=author /></td>
<td class="rev" >
{object.in_reply_to().id().map(|reply| view! {
<small><i><a class="clean" href={Uri::web(FetchKind::Object, &reply)} title={reply}>reply</a></i></small>
})}
<PrivacyMarker addressed=addressed />
<a class="clean hover ml-s" href={Uri::web(FetchKind::Object, object.id().unwrap_or_default())}>
<DateTime t=object.published() />
</a>
<sup><small><a class="clean ml-s" href={external_url} target="_blank">""</a></small></sup>
</td>
</tr>
</table>
{post}
<div class="mt-s ml-1 rev">
<ReplyButton n=comments target=oid.clone() />
<LikeButton n=likes liked=already_liked target=oid.clone() author=author_id private=!public />
<RepostButton n=shares target=oid />
</div>
}
}
#[component]
pub fn Summary(summary: Option<String>, children: Children) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
match summary.filter(|x| !x.is_empty()) {
None => children().into_view(),
Some(summary) => view! {
<details class="pa-s" prop:open=move || !config.get().collapse_content_warnings>
<summary>
<code class="cw center color ml-s w-100">{summary}</code>
</summary>
{children()}
</details>
}.into_view(),
}
}
#[component]
pub fn LikeButton(
n: u64,
target: String,
liked: bool,
author: String,
#[prop(optional)]
private: bool,
) -> impl IntoView {
let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(!liked);
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<span
class:emoji=clicked
class:emoji-btn=move || auth.present()
class:cursor=move || clicked.get() && auth.present()
class="ml-2"
on:click=move |_ev| {
if !auth.present() { return; }
if !clicked.get() { return; }
let to = apb::Node::links(vec![author.to_string()]);
let cc = if private { apb::Node::Empty } else {
apb::Node::links(vec![
apb::target::PUBLIC.to_string(),
format!("{URL_BASE}/users/{}/followers", auth.username())
])
};
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Like))
.set_object(apb::Node::link(target.clone()))
.set_to(to)
.set_cc(cc);
let target = target.clone();
spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await {
Ok(()) => {
set_clicked.set(false);
set_count.set(count.get() + 1);
if let Some(cached) = CACHE.get(&target) {
let mut new = (*cached).clone().set_liked_by_me(Some(true));
if let Some(likes) = new.likes().get() {
if let Some(count) = likes.total_items() {
new = new.set_likes(apb::Node::object(likes.clone().set_total_items(Some(count + 1))));
}
}
CACHE.put(target, Arc::new(new));
}
},
Err(e) => tracing::error!("failed sending like: {e}"),
}
});
}
>
{move || if count.get() > 0 { Some(view! { <small>{count}</small> })} else { None }}
""
</span>
}
}
#[component]
pub fn ReplyButton(n: u64, target: String) -> impl IntoView {
let reply = use_context::<ReplyControls>().expect("missing reply controls context");
let auth = use_context::<Auth>().expect("missing auth context");
let comments = if n > 0 {
Some(view! { <small>{n}</small> })
} else {
None
};
let _target = target.clone(); // TODO ughhhh useless clones
view! {
<span
class:emoji=move || !reply.reply_to.get().map_or(false, |x| x == _target)
// TODO can we merge these two classes conditions?
class:emoji-btn=move || auth.present()
class:cursor=move || auth.present()
class="ml-2"
on:click=move |_ev| if auth.present() { reply.reply(&target) }
>
{comments}
" 📨"
</span>
}
}
#[component]
pub fn RepostButton(n: u64, target: String) -> impl IntoView {
let (count, set_count) = create_signal(n);
let (clicked, set_clicked) = create_signal(true);
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<span
class:emoji=clicked
class:emoji-btn=move || auth.present()
class:cursor=move || clicked.get() && auth.present()
class="ml-2"
on:click=move |_ev| {
if !auth.present() { return; }
if !clicked.get() { return; }
set_clicked.set(false);
let to = apb::Node::links(vec![apb::target::PUBLIC.to_string()]);
let cc = apb::Node::links(vec![format!("{URL_BASE}/users/{}/followers", auth.username())]);
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Announce))
.set_object(apb::Node::link(target.clone()))
.set_to(to)
.set_cc(cc);
spawn_local(async move {
match Http::post(&auth.outbox(), &payload, auth).await {
Ok(()) => set_count.set(count.get() + 1),
Err(e) => tracing::error!("failed sending like: {e}"),
}
set_clicked.set(true);
});
}
>
{move || if count.get() > 0 { Some(view! { <small>{count}</small> })} else { None }}
" 🚀"
</span>
}
}

View file

@ -1,286 +0,0 @@
use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut};
use leptos::*;
use crate::prelude::*;
#[derive(Debug, Clone, Copy, Default)]
pub struct ReplyControls {
pub context: RwSignal<Option<String>>,
pub reply_to: RwSignal<Option<String>>,
}
impl ReplyControls {
pub fn reply(&self, oid: &str) {
if let Some(obj) = CACHE.get(oid) {
self.context.set(obj.context().id());
self.reply_to.set(obj.id().map(|x| x.to_string()));
}
}
pub fn clear(&self) {
self.context.set(None);
self.reply_to.set(None);
}
}
fn post_author(post_id: &str) -> Option<crate::Object> {
let usr = CACHE.get(post_id)?.attributed_to().id()?;
CACHE.get(&usr)
}
#[component]
pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let reply = use_context::<ReplyControls>().expect("missing reply controls");
let (posting, set_posting) = create_signal(false);
let (error, set_error) = create_signal(None);
let summary_ref: NodeRef<html::Input> = create_node_ref();
let content_ref: NodeRef<html::Textarea> = create_node_ref();
let public_ref: NodeRef<html::Input> = create_node_ref();
let followers_ref: NodeRef<html::Input> = create_node_ref();
let private_ref: NodeRef<html::Input> = create_node_ref();
view! {
<div>
{move ||
reply.reply_to.get().map(|r| {
let actor_strip = post_author(&r).map(|x| view! { <ActorStrip object=x /> });
view! {
<span class="nowrap">
<span
class="cursor emoji emoji-btn mr-s ml-s"
on:click=move|_| reply.clear()
title={format!("> {r} | ctx: {}", reply.context.get().unwrap_or_default())}
>
"📨"
</span>
<small>{actor_strip}</small>
</span>
}
})
}
<table class="align w-100">
<tr>
<td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td>
<td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
</tr>
</table>
<textarea rows="6" class="w-100" node_ref=content_ref title="content" placeholder="\n look at nothing\n what do you see?" ></textarea>
<table class="align rev w-100">
<tr>
<td><input id="priv-public" type="radio" name="privacy" value="public" title="public" node_ref=public_ref /></td>
<td><span class="emoji" title="public" >{PRIVACY_PUBLIC}</span></td>
<td class="w-100" rowspan="3">
<button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| {
set_posting.set(true);
spawn_local(async move {
let summary = get_if_some(summary_ref);
let content = content_ref.get().map(|x| x.value()).unwrap_or_default();
let (to, cc) = if get_checked(public_ref) {
(apb::Node::links(vec![apb::target::PUBLIC.to_string()]), apb::Node::links(vec![format!("{URL_BASE}/users/{}/followers", auth.username())]))
} else if get_checked(followers_ref) {
(apb::Node::links(vec![format!("{URL_BASE}/users/{}/followers", auth.username())]), apb::Node::Empty)
} else if get_checked(private_ref) {
(apb::Node::links(vec![]), apb::Node::Empty)
} else {
(apb::Node::Empty, apb::Node::Empty)
};
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_object_type(Some(apb::ObjectType::Note))
.set_summary(summary.as_deref())
.set_content(Some(&content))
.set_context(apb::Node::maybe_link(reply.context.get()))
.set_in_reply_to(apb::Node::maybe_link(reply.reply_to.get()))
.set_to(to)
.set_cc(cc);
match Http::post(&auth.outbox(), &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())),
Ok(()) => {
set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") }
if let Some(x) = content_ref.get() { x.set_value("") }
},
}
set_posting.set(false);
})
} >post</button>
</td>
</tr>
<tr>
<td><input id="priv-followers" type="radio" name="privacy" value="followers" title="followers" node_ref=followers_ref checked /></td>
<td><span class="emoji" title="followers" >{PRIVACY_FOLLOWERS}</span></td>
</tr>
<tr>
<td><input id="priv-private" type="radio" name="privacy" value="private" title="private" node_ref=private_ref /></td>
<td><span class="emoji" title="private" >{PRIVACY_PRIVATE}</span></td>
</tr>
</table>
{move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })}
</div>
}
}
#[component]
pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let (posting, set_posting) = create_signal(false);
let (error, set_error) = create_signal(None);
let (value, set_value) = create_signal("Like".to_string());
let (embedded, set_embedded) = create_signal(false);
let sensitive_ref: NodeRef<html::Input> = create_node_ref();
let summary_ref: NodeRef<html::Input> = create_node_ref();
let content_ref: NodeRef<html::Textarea> = create_node_ref();
let context_ref: NodeRef<html::Input> = create_node_ref();
let name_ref: NodeRef<html::Input> = create_node_ref();
let reply_ref: NodeRef<html::Input> = create_node_ref();
let to_ref: NodeRef<html::Input> = create_node_ref();
let object_id_ref: NodeRef<html::Input> = create_node_ref();
let bto_ref: NodeRef<html::Input> = create_node_ref();
let cc_ref: NodeRef<html::Input> = create_node_ref();
let bcc_ref: NodeRef<html::Input> = create_node_ref();
view! {
<div>
<table class="align w-100">
<tr>
<td>
<input type="checkbox" title="advanced" checked on:input=move |ev| {
advanced.set(event_target_checked(&ev))
}/>
</td>
<td class="w-100">
<select class="w-100" on:change=move |ev| set_value.set(event_target_value(&ev))>
<SelectOption value is="Create" />
<SelectOption value is="Like" />
<SelectOption value is="Follow" />
<SelectOption value is="Announce" />
<SelectOption value is="Accept" />
<SelectOption value is="Reject" />
<SelectOption value is="Undo" />
<SelectOption value is="Delete" />
<SelectOption value is="Update" />
</select>
</td>
<td>
<input type="checkbox" title="embedded object" on:input=move |ev| {
set_embedded.set(event_target_checked(&ev))
}/>
</td>
</tr>
</table>
<input class="w-100" type="text" node_ref=object_id_ref title="objectId" placeholder="objectId" />
<div class:hidden=move|| !embedded.get()>
<input class="w-100" type="text" node_ref=name_ref title="name" placeholder="name" />
<input class="w-100" type="text" node_ref=context_ref title="context" placeholder="context" />
<input class="w-100" type="text" node_ref=reply_ref title="inReplyTo" placeholder="inReplyTo" />
<table class="align w-100">
<tr>
<td><input type="checkbox" title="sensitive" checked node_ref=sensitive_ref/>
</td>
<td class="w-100">
<input class="w-100" type="text" node_ref=summary_ref title="summary" placeholder="summary" />
</td>
</tr>
</table>
<textarea rows="5" class="w-100" node_ref=content_ref title="content" placeholder="content" ></textarea>
</div>
<table class="w-100 align">
<tr>
<td class="w-66"><input class="w-100" type="text" node_ref=to_ref title="to" placeholder="to" value=apb::target::PUBLIC /></td>
<td class="w-66"><input class="w-100" type="text" node_ref=bto_ref title="bto" placeholder="bto" /></td>
</tr>
<tr>
<td class="w-33"><input class="w-100" type="text" node_ref=cc_ref title="cc" placeholder="cc" value=format!("{URL_BASE}/users/{}/followers", auth.username()) /></td>
<td class="w-33"><input class="w-100" type="text" node_ref=bcc_ref title="bcc" placeholder="bcc" /></td>
</tr>
</table>
<button class="w-100" type="button" prop:disabled=posting on:click=move |_| {
set_posting.set(true);
spawn_local(async move {
let content = content_ref.get().filter(|x| !x.value().is_empty()).map(|x| x.value());
let summary = get_if_some(summary_ref);
let name = get_if_some(name_ref);
let context = get_if_some(context_ref);
let reply = get_if_some(reply_ref);
let object_id = get_if_some(object_id_ref);
let to = get_vec_if_some(to_ref);
let bto = get_vec_if_some(bto_ref);
let cc = get_vec_if_some(cc_ref);
let bcc = get_vec_if_some(bcc_ref);
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(value.get().as_str().try_into().unwrap_or(apb::ActivityType::Create)))
.set_to(apb::Node::links(to.clone()))
.set_bto(apb::Node::links(bto.clone()))
.set_cc(apb::Node::links(cc.clone()))
.set_bcc(apb::Node::links(bcc.clone()))
.set_object(
if embedded.get() {
apb::Node::object(
serde_json::Value::Object(serde_json::Map::default())
.set_id(object_id.as_deref())
.set_object_type(Some(apb::ObjectType::Note))
.set_name(name.as_deref())
.set_summary(summary.as_deref())
.set_content(content.as_deref())
.set_in_reply_to(apb::Node::maybe_link(reply))
.set_context(apb::Node::maybe_link(context))
.set_to(apb::Node::links(to))
.set_bto(apb::Node::links(bto))
.set_cc(apb::Node::links(cc))
.set_bcc(apb::Node::links(bcc))
)
} else {
apb::Node::maybe_link(object_id)
}
);
let target_url = format!("{URL_BASE}/users/{}/outbox", auth.username());
match Http::post(&target_url, &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())),
Ok(()) => set_error.set(None),
}
set_posting.set(false);
})
} >post</button>
{move|| error.get().map(|x| view! { <blockquote class="mt-s">{x}</blockquote> })}
</div>
}
}
fn get_if_some(node: NodeRef<html::Input>) -> Option<String> {
node.get()
.map(|x| x.value())
.filter(|x| !x.is_empty())
}
fn get_vec_if_some(node: NodeRef<html::Input>) -> Vec<String> {
node.get()
.map(|x| x.value())
.filter(|x| !x.is_empty())
.map(|x|
x.split(',')
.map(|x| x.to_string())
.collect()
).unwrap_or_default()
}
fn get_checked(node: NodeRef<html::Input>) -> bool {
node.get()
.map(|x| x.checked())
.unwrap_or_default()
}
#[component]
fn SelectOption(is: &'static str, value: ReadSignal<String>) -> impl IntoView {
view! {
<option value=is selected=move || value.get() == is >
{is}
</option>
}
}

View file

@ -1,253 +0,0 @@
use std::{collections::BTreeSet, pin::Pin, sync::Arc};
use apb::{Activity, ActivityMut, Base, Object};
use leptos::*;
use crate::prelude::*;
#[derive(Debug, Clone, Copy)]
pub struct Timeline {
pub feed: RwSignal<Vec<String>>,
pub next: RwSignal<String>,
pub over: RwSignal<bool>,
pub loading: RwSignal<bool>,
}
impl Timeline {
pub fn new(url: String) -> Self {
let feed = create_rw_signal(vec![]);
let next = create_rw_signal(url);
let over = create_rw_signal(false);
let loading = create_rw_signal(false);
Timeline { feed, next, over, loading }
}
pub fn len(&self) -> usize {
self.feed.get().len()
}
pub fn is_empty(&self) -> bool {
self.feed.get().is_empty()
}
pub fn reset(&self, url: String) {
self.feed.set(vec![]);
self.next.set(url);
self.over.set(false);
}
pub async fn more(&self, auth: Auth) -> reqwest::Result<()> {
self.loading.set(true);
let res = self.more_inner(auth).await;
self.loading.set(false);
res
}
async fn more_inner(&self, auth: Auth) -> reqwest::Result<()> {
use apb::{Collection, CollectionPage};
let feed_url = self.next.get_untracked();
let collection : serde_json::Value = Http::fetch(&feed_url, auth).await?;
let activities : Vec<serde_json::Value> = collection
.ordered_items()
.collect();
let mut feed = self.feed.get_untracked();
let mut older = process_activities(activities, auth)
.await
.into_iter()
.filter(|x| !feed.contains(x))
.collect();
feed.append(&mut older);
self.feed.set(feed);
if let Some(next) = collection.next().id() {
self.next.set(next);
} else {
self.over.set(true);
}
Ok(())
}
}
#[component]
pub fn TimelineRepliesRecursive(tl: Timeline, root: String) -> impl IntoView {
let root_values = move || tl.feed
.get()
.into_iter()
.filter_map(|x| CACHE.get(&x))
.filter(|x| match x.object_type() {
Some(apb::ObjectType::Activity(apb::ActivityType::Create)) => {
let Some(oid) = x.object().id() else { return false; };
let Some(object) = CACHE.get(&oid) else { return false; };
let Some(reply) = object.in_reply_to().id() else { return false; };
reply == root
},
Some(apb::ObjectType::Activity(_)) => x.object().id().map(|o| o == root).unwrap_or(false),
_ => x.in_reply_to().id().map(|r| r == root).unwrap_or(false),
})
.collect::<Vec<crate::Object>>();
view! {
<For
each=root_values
key=|k| k.id().unwrap_or_default().to_string()
children=move |obj: crate::Object| {
let oid = obj.id().unwrap_or_default().to_string();
view! {
<div class="context depth-r">
<Item item=obj />
<div class="depth-r">
<TimelineRepliesRecursive tl=tl root=oid />
</div>
</div>
}
}
/ >
}
}
#[component]
pub fn TimelineReplies(tl: Timeline, root: String) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<div>
<TimelineRepliesRecursive tl=tl root=root />
</div>
<div class="center mt-1 mb-1" class:hidden=tl.over >
<button type="button"
prop:disabled=tl.loading
on:click=move |_| {
spawn_local(async move {
if let Err(e) = tl.more(auth).await {
tracing::error!("error fetching more items for timeline: {e}");
}
})
}
>{move || if tl.loading.get() { "loading" } else { "more" }}</button>
</div>
}
}
#[component]
pub fn TimelineFeed(tl: Timeline) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
view! {
<For
each=move || tl.feed.get()
key=|k| k.to_string()
children=move |id: String| {
match CACHE.get(&id) {
Some(i) => view! {
<Item item=i sep=true />
}.into_view(),
None => view! {
<p><code>{id}</code>" "[<a href={uri}>go</a>]</p>
<hr />
}.into_view(),
}
}
/ >
<div class="center mt-1 mb-1" class:hidden=tl.over >
<button type="button"
prop:disabled=tl.loading
on:click=move |_| {
spawn_local(async move {
if let Err(e) = tl.more(auth).await {
tracing::error!("error fetching more items for timeline: {e}");
}
})
}
>{move || if tl.loading.get() { "loading" } else { "more" }}</button>
</div>
}
}
async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> Vec<String> {
let mut sub_tasks : Vec<Pin<Box<dyn futures::Future<Output = ()>>>> = Vec::new();
let mut gonna_fetch = BTreeSet::new();
let mut actors_seen = BTreeSet::new();
let mut out = Vec::new();
for activity in activities {
let activity_type = activity.activity_type().unwrap_or(apb::ActivityType::Activity);
// save embedded object if present
if let Some(object) = activity.object().get() {
// also fetch actor attributed to
if let Some(attributed_to) = object.attributed_to().id() {
actors_seen.insert(attributed_to);
}
if let Some(object_uri) = object.id() {
CACHE.put(object_uri.to_string(), Arc::new(object.clone()));
} else {
tracing::warn!("embedded object without id: {object:?}");
}
} else { // try fetching it
if let Some(object_id) = activity.object().id() {
if !gonna_fetch.contains(&object_id) {
let fetch_kind = match activity_type {
apb::ActivityType::Follow => FetchKind::User,
_ => FetchKind::Object,
};
gonna_fetch.insert(object_id.clone());
sub_tasks.push(Box::pin(fetch_and_update_with_user(fetch_kind, object_id, auth)));
}
}
}
// save activity, removing embedded object
let object_id = activity.object().id();
if let Some(activity_id) = activity.id() {
out.push(activity_id.to_string());
CACHE.put(
activity_id.to_string(),
Arc::new(activity.clone().set_object(apb::Node::maybe_link(object_id)))
);
} else if let Some(object_id) = activity.object().id() {
out.push(object_id);
}
if let Some(uid) = activity.attributed_to().id() {
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(Box::pin(fetch_and_update(FetchKind::User, uid, auth)));
}
}
if let Some(uid) = activity.actor().id() {
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
gonna_fetch.insert(uid.clone());
sub_tasks.push(Box::pin(fetch_and_update(FetchKind::User, uid, auth)));
}
}
}
for user in actors_seen {
sub_tasks.push(Box::pin(fetch_and_update(FetchKind::User, user, auth)));
}
futures::future::join_all(sub_tasks).await;
out
}
async fn fetch_and_update(kind: FetchKind, id: String, auth: Auth) {
match Http::fetch(&Uri::api(kind, &id, false), auth).await {
Ok(data) => CACHE.put(id, Arc::new(data)),
Err(e) => console_warn(&format!("could not fetch '{id}': {e}")),
}
}
async fn fetch_and_update_with_user(kind: FetchKind, id: String, auth: Auth) {
fetch_and_update(kind.clone(), id.clone(), auth).await;
if let Some(obj) = CACHE.get(&id) {
if let Some(actor_id) = match kind {
FetchKind::Object => obj.attributed_to().id(),
FetchKind::Activity => obj.actor().id(),
FetchKind::User | FetchKind::Context => None,
} {
fetch_and_update(FetchKind::User, actor_id, auth).await;
}
}
}

View file

@ -1,105 +0,0 @@
use leptos::*;
use crate::{prelude::*, DEFAULT_AVATAR_URL};
use apb::{Activity, ActivityMut, Actor, Base, Object, ObjectMut};
#[component]
pub fn ActorStrip(object: crate::Object) -> impl IntoView {
let actor_id = object.id().unwrap_or_default().to_string();
let username = object.preferred_username().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
let avatar = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
view! {
<a href={Uri::web(FetchKind::User, &actor_id)} class="clean hover">
<img src={avatar} class="avatar-inline mr-s" /><b>{username}</b><small>@{domain}</small>
</a>
}
}
#[component]
pub fn ActorBanner(object: crate::Object) -> impl IntoView {
match object.as_ref() {
serde_json::Value::String(id) => view! {
<div><b>?</b>" "<a class="clean hover" href={Uri::web(FetchKind::User, id)}>{Uri::pretty(id)}</a></div>
},
serde_json::Value::Object(_) => {
let uid = object.id().unwrap_or_default().to_string();
let uri = Uri::web(FetchKind::User, &uid);
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
let display_name = object.name().unwrap_or_default().to_string();
let username = object.preferred_username().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
view! {
<div>
<table class="align" >
<tr>
<td rowspan="2" ><a href={uri.clone()} ><img class="avatar-circle inline-avatar" src={avatar_url} /></a></td>
<td><b>{display_name}</b></td>
</tr>
<tr>
<td class="top" ><a class="hover" href={uri} ><small>{username}@{domain}</small></a></td>
</tr>
</table>
</div>
}
},
_ => view! {
<div><b>invalid actor</b></div>
}
}
}
#[component]
pub fn FollowRequestButtons(activity_id: String, actor_id: String) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
// TODO lmao what is going on with this double move / triple clone ???????????
let _activity_id = activity_id.clone();
let _actor_id = actor_id.clone();
let from_actor = CACHE.get(&activity_id).map(|x| x.actor().id().unwrap_or_default()).unwrap_or_default();
let _from_actor = from_actor.clone();
if actor_id == auth.user_id() {
Some(view! {
<input type="submit" value="accept"
on:click=move |_| {
let activity_id = _activity_id.clone();
let actor_id = _from_actor.clone();
spawn_local(async move {
send_follow_response(
apb::ActivityType::Accept(apb::AcceptType::Accept),
activity_id,
actor_id,
auth
).await
})
}
/>
<span class="ma-1"></span>
<input type="submit" value="reject"
on:click=move |_| {
let activity_id = activity_id.clone();
let actor_id = from_actor.clone();
spawn_local(async move {
send_follow_response(
apb::ActivityType::Reject(apb::RejectType::Reject),
activity_id,
actor_id,
auth
).await
})
}
/>
})
} else {
None
}
}
async fn send_follow_response(kind: apb::ActivityType, target: String, to: String, auth: Auth) {
let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(kind))
.set_object(apb::Node::link(target))
.set_to(apb::Node::links(vec![to]));
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
tracing::error!("failed posting follow response: {e}");
}
}

View file

@ -1,47 +0,0 @@
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, serde_default::DefaultFromSerde)]
pub struct Config {
#[serde(default)]
pub filters: FiltersConfig,
#[serde_inline_default(true)]
pub collapse_content_warnings: bool,
#[serde_inline_default(true)]
pub loop_videos: bool,
}
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, serde_default::DefaultFromSerde)]
pub struct FiltersConfig {
#[serde_inline_default(false)]
pub likes: bool,
#[serde_inline_default(true)]
pub creates: bool,
#[serde_inline_default(true)]
pub announces: bool,
#[serde_inline_default(true)]
pub follows: bool,
#[serde_inline_default(true)]
pub orphans: bool,
}
impl FiltersConfig {
pub fn visible(&self, object_type: apb::ObjectType) -> bool {
match object_type {
apb::ObjectType::Note | apb::ObjectType::Document(_) => self.orphans,
apb::ObjectType::Activity(apb::ActivityType::Like | apb::ActivityType::EmojiReact) => self.likes,
apb::ObjectType::Activity(apb::ActivityType::Create) => self.creates,
apb::ObjectType::Activity(apb::ActivityType::Announce) => self.announces,
apb::ObjectType::Activity(
apb::ActivityType::Follow | apb::ActivityType::Accept(_) | apb::ActivityType::Reject(_)
) => self.follows,
_ => true,
}
}
}

View file

@ -1,174 +0,0 @@
mod auth;
mod app;
mod components;
mod page;
mod config;
pub use app::App;
pub use config::Config;
pub use auth::Auth;
pub mod prelude;
pub const URL_BASE: &str = "https://feditest.alemi.dev";
pub const URL_PREFIX: &str = "/web";
pub const URL_SENSITIVE: &str = "https://cdn.alemi.dev/social/nsfw.png";
pub const DEFAULT_AVATAR_URL: &str = "https://cdn.alemi.dev/social/gradient.png";
pub const NAME: &str = "μ";
use std::sync::Arc;
lazy_static::lazy_static! {
pub static ref CACHE: ObjectCache = ObjectCache::default();
}
pub type Object = Arc<serde_json::Value>;
#[derive(Debug, Clone, Default)]
pub struct ObjectCache(pub Arc<dashmap::DashMap<String, Object>>);
impl ObjectCache {
pub fn get(&self, k: &str) -> Option<Object> {
self.0.get(k).map(|x| x.clone())
}
pub fn get_or(&self, k: &str, or: Object) -> Object {
self.get(k).unwrap_or(or)
}
pub fn put(&self, k: String, v: Object) {
self.0.insert(k, v);
}
pub async fn fetch(&self, k: &str, kind: FetchKind) -> reqwest::Result<Object> {
match self.get(k) {
Some(x) => Ok(x),
None => {
let obj = reqwest::get(Uri::api(kind, k, true))
.await?
.json::<serde_json::Value>()
.await?;
self.put(k.to_string(), Arc::new(obj));
Ok(self.get(k).expect("not found in cache after insertion"))
}
}
}
}
#[derive(Debug, Clone)]
pub enum FetchKind {
User,
Object,
Activity,
Context,
}
impl AsRef<str> for FetchKind {
fn as_ref(&self) -> &str {
match self {
Self::User => "users",
Self::Object => "objects",
Self::Activity => "activities",
Self::Context => "context",
}
}
}
pub struct Http;
impl Http {
pub async fn request<T: serde::ser::Serialize>(
method: reqwest::Method,
url: &str,
data: Option<&T>,
auth: Auth,
) -> reqwest::Result<reqwest::Response> {
use leptos::SignalGetUntracked;
let mut req = reqwest::Client::new()
.request(method, url);
if let Some(auth) = auth.token.get_untracked().filter(|x| !x.is_empty()) {
req = req.header("Authorization", format!("Bearer {}", auth));
}
if let Some(data) = data {
req = req.json(data);
}
req.send().await
}
pub async fn fetch<T: serde::de::DeserializeOwned>(url: &str, token: Auth) -> reqwest::Result<T> {
Self::request::<()>(reqwest::Method::GET, url, None, token)
.await?
.error_for_status()?
.json::<T>()
.await
}
pub async fn post<T: serde::ser::Serialize>(url: &str, data: &T, token: Auth) -> reqwest::Result<()> {
Self::request(reqwest::Method::POST, url, Some(data), token)
.await?
.error_for_status()?;
Ok(())
}
}
pub struct Uri;
impl Uri {
pub fn full(kind: FetchKind, id: &str) -> String {
let kind = kind.as_ref();
if id.starts_with('+') {
id.replace('+', "https://").replace('@', "/")
} else {
format!("{URL_BASE}/{kind}/{id}")
}
}
pub fn pretty(url: &str) -> String {
if url.len() < 50 {
url.replace("https://", "")
} else {
format!("{}..", url.replace("https://", "").get(..50).unwrap_or_default())
}.replace('/', "\u{200B}/\u{200B}")
}
pub fn short(url: &str) -> String {
if url.starts_with(URL_BASE) {
url.split('/').last().unwrap_or_default().to_string()
} else {
url.replace("https://", "+").replace('/', "@")
}
}
/// convert url id to valid frontend view id:
/// /web/users/test
/// /web/objects/+social.alemi.dev@objects@1204kasfkl
/// accepts:
/// - https://my.domain.net/users/root
/// - https://other.domain.net/unexpected/path/root
/// - +other.domain.net@users@root
/// - root
pub fn web(kind: FetchKind, url: &str) -> String {
let kind = kind.as_ref();
format!("/web/{kind}/{}", Self::short(url))
}
/// convert url id to valid backend api id
/// https://feditest.alemi.dev/users/test
/// https://feditest.alemi.dev/users/+social.alemi.dev@users@alemi
/// accepts:
/// - https://my.domain.net/users/root
/// - https://other.domain.net/unexpected/path/root
/// - +other.domain.net@users@root
/// - root
pub fn api(kind: FetchKind, url: &str, fetch: bool) -> String {
let kind = kind.as_ref();
format!("{URL_BASE}/{kind}/{}{}", Self::short(url), if fetch { "?fetch=true" } else { "" })
}
}

View file

@ -1,6 +0,0 @@
fn main() {
_ = console_log::init_with_level(log::Level::Info);
console_error_panic_hook::set_once();
leptos::mount_to_body(upub_web::App);
}

View file

@ -1,19 +0,0 @@
use leptos::*;
use crate::prelude::*;
#[component]
pub fn AboutPage() -> impl IntoView {
view! {
<div>
<Breadcrumb>about</Breadcrumb>
<div class="mt-s mb-s" >
<p><code>μpub</code>" is a micro social network powered by "<a href="">ActivityPub</a></p>
<p><i>"the "<a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a>" is an ensemble of social networks, which, while independently hosted, can communicate with each other"</i></p>
<p>content is aggregated in timelines, logged out users can only access the global server timeline</p>
<hr />
<p>"while somewhat usable, "<code>μpub</code>" is under active development and still lacks some mainstream features (such as hashtags or lists)"</p>
<p>"if you would like to contribute to "<code>μpub</code>"'s development, get in touch and check "<a href="https://github.com/alemidev/upub" target="_blank">github</a>" or "<a href="https://moonlit.technology/alemi/upub.git" target="_blank">forgejo</a></p>
</div>
</div>
}
}

View file

@ -1,67 +0,0 @@
use leptos::*;
use crate::prelude::*;
#[component]
pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
macro_rules! get_cfg {
(filter $field:ident) => {
move || config.get().filters.$field
};
($field:ident) => {
move || config.get().$field
};
}
macro_rules! set_cfg {
($field:ident) => {
move |ev| {
let mut mock = config.get();
mock.$field = event_target_checked(&ev);
setter.set(mock);
}
};
(filter $field:ident) => {
move |ev| {
let mut mock = config.get();
mock.filters.$field = event_target_checked(&ev);
setter.set(mock);
}
};
}
view! {
<div>
<Breadcrumb>config</Breadcrumb>
<p class="center mt-0"><small>config is saved in your browser local storage</small></p>
<p>
<span title="embedded video attachments will loop like gifs if this option is enabled">
<input type="checkbox" class="mr-1"
prop:checked=get_cfg!(loop_videos)
on:input=set_cfg!(loop_videos)
/> loop videos
</span>
</p>
<p>
<span title="any post with a summary is considered to have a content warning, and collapsed by default if this option is enabled">
<input type="checkbox" class="mr-1"
prop:checked=get_cfg!(collapse_content_warnings)
on:input=set_cfg!(collapse_content_warnings)
/> collapse content warnings
</span>
</p>
<hr />
<p><code title="unchecked elements won't show in timelines">filters</code></p>
<ul>
<li><span title="like activities"><input type="checkbox" prop:checked=get_cfg!(filter likes) on:input=set_cfg!(filter likes) />" likes"</span></li>
<li><span title="create activities with object"><input type="checkbox" prop:checked=get_cfg!(filter creates) on:input=set_cfg!(filter creates)/>" creates"</span></li>
<li><span title="announce activities with object"><input type="checkbox" prop:checked=get_cfg!(filter announces) on:input=set_cfg!(filter announces) />" announces"</span></li>
<li><span title="follow, accept and reject activities"><input type="checkbox" prop:checked=get_cfg!(filter follows) on:input=set_cfg!(filter follows) />" follows"</span></li>
<li><span title="objects without a related activity to display"><input type="checkbox" prop:checked=get_cfg!(filter orphans) on:input=set_cfg!(filter orphans) />" orphans"</span></li>
</ul>
<hr />
<p><a href="/web/config/dev" title="access the devtools page">devtools</a></p>
</div>
}
}

View file

@ -1,69 +0,0 @@
use std::sync::Arc;
use leptos::*;
use crate::prelude::*;
#[component]
pub fn DebugPage() -> impl IntoView {
let (object, set_object) = create_signal(Arc::new(serde_json::Value::String(
"use this view to fetch remote AP objects and inspect their content".into())
));
let cached_ref: NodeRef<html::Input> = create_node_ref();
let auth = use_context::<Auth>().expect("missing auth context");
let (query, set_query) = create_signal("".to_string());
view! {
<div>
<Breadcrumb back=true>config :: devtools</Breadcrumb>
<div class="mt-1" >
<form on:submit=move|ev| {
ev.prevent_default();
let cached = cached_ref.get().map(|x| x.checked()).unwrap_or_default();
let fetch_url = query.get();
if cached {
match CACHE.get(&fetch_url) {
Some(x) => set_object.set(x),
None => set_object.set(Arc::new(serde_json::Value::String("not in cache!".into()))),
}
} else {
let url = format!("{URL_BASE}/dbg?id={fetch_url}");
spawn_local(async move { set_object.set(Arc::new(debug_fetch(&url, auth).await)) });
}
} >
<table class="align w-100" >
<tr>
<td>
<small><a
href={move|| Uri::web(FetchKind::Object, &query.get())}
>obj</a>
" "
<a
href={move|| Uri::web(FetchKind::User, &query.get())}
>usr</a></small>
</td>
<td class="w-100"><input class="w-100" type="text" on:input=move|ev| set_query.set(event_target_value(&ev)) placeholder="AP id" /></td>
<td><input type="submit" class="w-100" value="fetch" /></td>
<td><input type="checkbox" title="cached" value="cached" node_ref=cached_ref /></td>
</tr>
</table>
</form>
</div>
<pre class="ma-1" >
{move || serde_json::to_string_pretty(object.get().as_ref()).unwrap_or("unserializable".to_string())}
</pre>
</div>
}
}
// this is a rather weird way to fetch but i want to see the bare error text if it fails!
async fn debug_fetch(url: &str, token: Auth) -> serde_json::Value {
match Http::request::<()>(reqwest::Method::GET, url, None, token).await {
Err(e) => serde_json::Value::String(format!("[!] failed sending request: {e}")),
Ok(res) => match res.text().await {
Err(e) => serde_json::Value::String(format!("[!] invalid response body: {e}")),
Ok(x) => match serde_json::from_str(&x) {
Err(_) => serde_json::Value::String(x),
Ok(v) => v,
},
}
}
}

View file

@ -1,23 +0,0 @@
mod about;
pub use about::AboutPage;
mod config;
pub use config::ConfigPage;
mod debug;
pub use debug::DebugPage;
mod object;
pub use object::ObjectPage;
mod register;
pub use register::RegisterPage;
mod search;
pub use search::SearchPage;
mod timeline;
pub use timeline::TimelinePage;
mod user;
pub use user::UserPage;

View file

@ -1,84 +0,0 @@
use std::sync::Arc;
use leptos::*;
use leptos_router::*;
use crate::prelude::*;
use apb::{Base, Object};
#[component]
pub fn ObjectPage(tl: Timeline) -> impl IntoView {
let params = use_params_map();
let auth = use_context::<Auth>().expect("missing auth context");
let mut uid = params.get().get("id")
.cloned()
.unwrap_or_default()
.replace("/web/objects/", "")
.replacen('+', "https://", 1)
.replace('@', "/");
if !uid.starts_with("http") {
uid = format!("{URL_BASE}/web/objects/{uid}");
}
let object = create_local_resource(move || params.get().get("id").cloned().unwrap_or_default(), move |oid| {
async move {
match CACHE.get(&Uri::full(FetchKind::Object, &oid)) {
Some(x) => Some(x.clone()),
None => {
let obj = Http::fetch::<serde_json::Value>(&Uri::api(FetchKind::Object, &oid, true), auth).await.ok()?;
let obj = Arc::new(obj);
if let Some(author) = obj.attributed_to().id() {
if let Ok(user) = Http::fetch::<serde_json::Value>(
&Uri::api(FetchKind::User, &author, true), auth
).await {
CACHE.put(Uri::full(FetchKind::User, &author), Arc::new(user));
}
}
CACHE.put(Uri::full(FetchKind::Object, &oid), obj.clone());
Some(obj)
}
}
}
});
view! {
<div>
<Breadcrumb back=true >
objects::view
<a
class="clean ml-1" href="#"
class:hidden=move || tl.is_empty()
on:click=move |_| {
tl.reset(tl.next.get().split('?').next().unwrap_or_default().to_string());
spawn_local(async move {
if let Err(e) = tl.more(auth).await {
tracing::error!("error fetching more items for timeline: {e}");
}
})
}><span class="emoji">
"\u{1f5d8}"
</span></a>
</Breadcrumb>
<div class="ma-2" >
{move || match object.get() {
None => view! { <p class="center"> loading ... </p> }.into_view(),
Some(None) => {
let uid = uid.clone();
view! { <p class="center"><code>loading failed</code><sup><small><a class="clean" href={uid} target="_blank">""</a></small></sup></p> }.into_view()
},
Some(Some(o)) => {
let object = o.clone();
let tl_url = format!("{}/page", Uri::api(FetchKind::Context, &o.context().id().unwrap_or_default(), false));
if !tl.next.get().starts_with(&tl_url) {
tl.reset(tl_url);
}
view!{
<Object object=object />
<div class="ml-1 mr-1 mt-2">
<TimelineReplies tl=tl root=o.id().unwrap_or_default().to_string() />
</div>
}.into_view()
},
}}
</div>
</div>
}
}

View file

@ -1,77 +0,0 @@
use leptos::*;
use reqwest::Method;
use crate::prelude::*;
#[component]
pub fn RegisterPage() -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let username_ref: NodeRef<html::Input> = create_node_ref();
let password_ref: NodeRef<html::Input> = create_node_ref();
let display_name_ref: NodeRef<html::Input> = create_node_ref();
let summary_ref: NodeRef<html::Input> = create_node_ref();
let avatar_url_ref: NodeRef<html::Input> = create_node_ref();
let banner_url_ref: NodeRef<html::Input> = create_node_ref();
let (error, set_error) = create_signal(None);
view! {
<div class="two-col">
<Breadcrumb>register</Breadcrumb>
<div class="border ma-2">
<form on:submit=move|ev| {
ev.prevent_default();
logging::log!("registering new user...");
let email = username_ref.get().map(|x| x.value()).unwrap_or("".into());
let password = password_ref.get().map(|x| x.value()).unwrap_or("".into());
spawn_local(async move {
match Http::request::<()>(
Method::PUT, &format!("{URL_BASE}/auth"), None, auth
).await {
Ok(x) => {},
Err(e) => set_error.set(Some(
view! { <blockquote>{e.to_string()}</blockquote> }
)),
}
});
} >
<div class="col-side mb-0">username</div>
<div class="col-main">
<input class="w-100" type="email" node_ref=username_ref placeholder="doll" />
</div>
<div class="col-side mb-0">password</div>
<div class="col-main">
<input class="w-100" type="password" node_ref=password_ref placeholder="±·ì¥ì¤uª]*«P³.ÐvkÏÚ;åÍì§ÕºöAQ¿SnÔý" />
</div>
<div class="col-side mb-0"><hr /></div>
<div class="col-main"><hr class="hidden-on-mobile" /></div>
<div class="col-side mb-0">display name</div>
<div class="col-main">
<input class="w-100" type="text" node_ref=display_name_ref placeholder="bmdieGo="/>
</div>
<div class="col-side mb-0">summary</div>
<div class="col-main">
<input class="w-100" type="text" node_ref=summary_ref placeholder="when you lose control of yourself, who's controlling you?" />
</div>
<div class="col-side mb-0">avatar url</div>
<div class="col-main">
<input class="w-100" type="text" node_ref=avatar_url_ref placeholder="https://cdn.alemi.dev/social/circle-square.png" />
</div>
<div class="col-side mb-0">banner url</div>
<div class="col-main">
<input class="w-100" type="text" node_ref=banner_url_ref placeholder="https://cdn.alemi.dev/social/gradient.png" />
</div>
<div class="col-side mb-0"><hr /></div>
<div class="col-main"><hr class="hidden-on-mobile" /></div>
<input class="w-100" type="submit" value="register" />
</form>
</div>
<p>{error}</p>
</div>
}
}

View file

@ -1,59 +0,0 @@
use std::sync::Arc;
use leptos::*;
use leptos_router::*;
use crate::prelude::*;
#[component]
pub fn SearchPage() -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
let user = create_local_resource(
move || use_query_map().get().get("q").cloned().unwrap_or_default(),
move |q| {
let user_fetch = Uri::api(FetchKind::User, &q, true);
async move { Some(Arc::new(Http::fetch::<serde_json::Value>(&user_fetch, auth).await.ok()?)) }
}
);
let object = create_local_resource(
move || use_query_map().get().get("q").cloned().unwrap_or_default(),
move |q| {
let object_fetch = Uri::api(FetchKind::Object, &q, true);
async move { Some(Arc::new(Http::fetch::<serde_json::Value>(&object_fetch, auth).await.ok()?)) }
}
);
view! {
<Breadcrumb>search</Breadcrumb>
<blockquote class="mt-3 mb-3">
<details open>
<summary class="mb-2">
<code class="cw center color ml-s w-100">users</code>
</summary>
<div class="pb-1">
{move || match user.get() {
None => view! { <p class="center"><small>searching...</small></p> },
Some(None) => view! { <p class="center"><code>N/A</code></p> },
Some(Some(u)) => view! { <p><ActorBanner object=u /></p> },
}}
</div>
</details>
</blockquote>
<blockquote class="mt-3 mb-3">
<details open>
<summary class="mb-2">
<code class="cw center color ml-s w-100">objects</code>
</summary>
<div class="pb-1">
{move || match object.get() {
None => view! { <p class="center"><small>searching...</small></p> },
Some(None) => view!{ <p class="center"><code>N/A</code></p> },
Some(Some(o)) => view! { <p><Object object=o /></p> },
}}
</div>
</details>
</blockquote>
}
}

Some files were not shown because too many files have changed in this diff Show more