Compare commits
No commits in common. "dev" and "tci" have entirely different histories.
103 changed files with 990 additions and 11683 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,2 @@
|
||||||
/target
|
/target
|
||||||
/apb/target
|
/apb/target
|
||||||
/web/dist
|
|
||||||
|
|
8
.tci
8
.tci
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "building release binary"
|
echo "building release binary"
|
||||||
cargo build --release --all-features -j 1 # limit memory usage
|
cargo build --release --all-features
|
||||||
echo "stopping service"
|
echo "stopping service"
|
||||||
systemctl --user stop upub
|
systemctl --user stop upub
|
||||||
echo "installing new binary"
|
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
|
/opt/bin/upub --db "sqlite:///srv/tci/upub.db" --domain https://feditest.alemi.dev migrate
|
||||||
echo "restarting service"
|
echo "restarting service"
|
||||||
systemctl --user start upub
|
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"
|
echo "done"
|
||||||
|
|
5023
Cargo.lock
generated
5023
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
@ -1,9 +1,9 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["apb", "web", "mdhtml"]
|
members = ["apb"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "upub"
|
name = "upub"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = [ "alemi <me@alemi.dev>" ]
|
authors = [ "alemi <me@alemi.dev>" ]
|
||||||
description = "Traits and types to handle ActivityPub objects"
|
description = "Traits and types to handle ActivityPub objects"
|
||||||
|
@ -24,32 +24,26 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1.8", features = ["v4"] }
|
uuid = { version = "1.8", features = ["v4"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_default = "0.1"
|
|
||||||
serde-inline-default = "0.2"
|
|
||||||
toml = "0.8"
|
|
||||||
mdhtml = { path = "mdhtml", features = ["markdown"] }
|
|
||||||
jrd = "0.1"
|
jrd = "0.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
futures = "0.3"
|
|
||||||
tokio = { version = "1.35", features = ["full"] } # TODO slim this down
|
tokio = { version = "1.35", features = ["full"] } # TODO slim this down
|
||||||
sea-orm = { version = "0.12", features = ["macros", "sqlx-sqlite", "runtime-tokio-rustls"] }
|
sea-orm = { version = "0.12", features = ["macros", "sqlx-sqlite", "runtime-tokio-rustls"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
axum = "0.7"
|
axum = "0.7"
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
apb = { path = "apb", features = ["unstructured", "fetch", "orm"] }
|
||||||
apb = { path = "apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub"] }
|
|
||||||
# nodeinfo = "0.0.2" # the version on crates.io doesn't re-export necessary types to build the struct!!!
|
# 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" }
|
nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "e865094804" }
|
||||||
|
http-signature-normalization = "0.7.0"
|
||||||
# migrations
|
# migrations
|
||||||
sea-orm-migration = { version = "0.12", optional = true }
|
sea-orm-migration = { version = "0.12", optional = true }
|
||||||
# mastodon
|
# mastodon
|
||||||
mastodon-async-entities = { version = "1.1.0", optional = true }
|
mastodon-async-entities = { version = "1.1.0", optional = true }
|
||||||
time = { version = "0.3", features = ["serde"], optional = true }
|
time = { version = "0.3", features = ["serde"], optional = true }
|
||||||
async-recursion = "1.1"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["migrations", "cli"]
|
default = ["faker", "migrations", "mastodon"]
|
||||||
cli = []
|
faker = []
|
||||||
migrations = ["dep:sea-orm-migration"]
|
migrations = ["dep:sea-orm-migration"]
|
||||||
mastodon = ["dep:mastodon-async-entities", "dep:time"]
|
mastodon = ["dep:mastodon-async-entities", "dep:time"]
|
||||||
|
|
66
README.md
66
README.md
|
@ -1,44 +1,14 @@
|
||||||
# μpub
|
# μpub
|
||||||
> micro social network, federated
|
> 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)
|
all interactions must happen with ActivityPub's client-server methods (basically POST your activities to your outbox)
|
||||||
|
|
||||||
μ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
|
|
||||||
|
|
||||||
a test instance is _usually_ available at [feditest.alemi.dev](https://feditest.alemi.dev)
|
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
|
## progress
|
||||||
|
|
||||||
- [x] barebone actors
|
- [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 inbox
|
||||||
- [x] process barebones outbox
|
- [x] process barebones outbox
|
||||||
- [x] http signatures
|
- [x] http signatures
|
||||||
- [x] privacy, targets, scopes
|
- [ ] privacy, targets, scopes
|
||||||
- [x] simple web client
|
- [ ] client api (mastodon/pleroma)
|
||||||
- [x] announce (boosts)
|
- [ ] hashtags, discovery
|
||||||
- [x] threads
|
- [ ] a custom frontend maybe?
|
||||||
- [x] remote media
|
- [ ] more optimized database schema
|
||||||
- [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
|
|
||||||
|
|
||||||
## what about the name?
|
## 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))
|
||||||
|
|
|
@ -24,15 +24,8 @@ sea-orm = { version = "0.12", optional = true }
|
||||||
reqwest = { version = "0.12", features = ["json"], optional = true }
|
reqwest = { version = "0.12", features = ["json"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["activitypub-miscellaneous-terms"]
|
default = []
|
||||||
# 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
|
|
||||||
orm = ["dep:sea-orm"]
|
orm = ["dep:sea-orm"]
|
||||||
fetch = ["dep:reqwest"]
|
fetch = ["dep:reqwest"]
|
||||||
# providers
|
|
||||||
unstructured = ["dep:serde_json"]
|
unstructured = ["dep:serde_json"]
|
||||||
#TODO eventually also make a structured base?
|
# TODO eventually also make a structured base?
|
||||||
|
|
|
@ -15,7 +15,7 @@ impl PublicKey for serde_json::Value {
|
||||||
crate::getter! { owner -> &str }
|
crate::getter! { owner -> &str }
|
||||||
|
|
||||||
fn public_key_pem(&self) -> &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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -113,7 +113,7 @@ pub use types::{
|
||||||
offer::{Offer, OfferMut, OfferType},
|
offer::{Offer, OfferMut, OfferType},
|
||||||
reject::{Reject, RejectMut, RejectType},
|
reject::{Reject, RejectMut, RejectType},
|
||||||
},
|
},
|
||||||
actor::{Actor, ActorMut, ActorType, Endpoints, EndpointsMut},
|
actor::{Actor, ActorMut, ActorType},
|
||||||
collection::{
|
collection::{
|
||||||
Collection, CollectionMut, CollectionType,
|
Collection, CollectionMut, CollectionType,
|
||||||
page::{CollectionPage, CollectionPageMut}
|
page::{CollectionPage, CollectionPageMut}
|
||||||
|
|
|
@ -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) => {
|
($name:ident::$rename:ident -> &str) => {
|
||||||
fn $name(&self) -> Option<&str> {
|
fn $name(&self) -> Option<&str> {
|
||||||
self.get(stringify!($rename))?.as_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) => {
|
($name:ident -> &str) => {
|
||||||
paste::item! {
|
paste::item! {
|
||||||
fn [< set_$name >](mut self, val: Option<&str>) -> Self {
|
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())),
|
obj, key, Some(serde_json::Value::String(l.href().to_string())),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
crate::Node::Array(arr) => {
|
crate::Node::Array(_) => {
|
||||||
set_maybe_value(
|
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 => {
|
crate::Node::Empty => {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
/// ActivityPub object node, representing either nothing, something, a link to something or
|
/// ActivityPub object node, representing either nothing, something, a link to something or
|
||||||
/// multiple things
|
/// multiple things
|
||||||
pub enum Node<T : super::Base> {
|
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>),
|
Object(Box<T>),
|
||||||
Link(Box<dyn crate::Link + Sync + Send>), // TODO feature flag to toggle these maybe?
|
Link(Box<dyn super::Link>),
|
||||||
Empty,
|
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> {
|
impl<T : super::Base + Clone> Iterator for Node<T> {
|
||||||
type Item = T;
|
type Item = T;
|
||||||
|
|
||||||
|
@ -26,7 +23,7 @@ impl<T : super::Base + Clone> Iterator for Node<T> {
|
||||||
let x = match self {
|
let x = match self {
|
||||||
Self::Empty => return None,
|
Self::Empty => return None,
|
||||||
Self::Link(_) => 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::Object(x) => *x.clone(), // TODO needed because next() on object can't get value without owning
|
||||||
};
|
};
|
||||||
*self = Self::Empty;
|
*self = Self::Empty;
|
||||||
|
@ -40,7 +37,7 @@ impl<T : super::Base> Node<T> {
|
||||||
match self {
|
match self {
|
||||||
Node::Empty | Node::Link(_) => None,
|
Node::Empty | Node::Link(_) => None,
|
||||||
Node::Object(x) => Some(x),
|
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 {
|
match self {
|
||||||
Node::Empty | Node::Link(_) => None,
|
Node::Empty | Node::Link(_) => None,
|
||||||
Node::Object(x) => Some(*x),
|
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
|
/// true only if Node is empty
|
||||||
pub fn is_nothing(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
matches!(self, Node::Empty)
|
matches!(self, Node::Empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,12 +70,6 @@ impl<T : super::Base> Node<T> {
|
||||||
matches!(self, Node::Array(_))
|
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)
|
/// returns number of contained items (links count as items for len)
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
match self {
|
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
|
/// returns id of object: url for link, id for object, None if empty or array
|
||||||
pub fn id(&self) -> Option<String> {
|
pub fn id(&self) -> Option<String> {
|
||||||
match self {
|
match self {
|
||||||
Node::Empty => None,
|
Node::Empty | Node::Array(_) => None,
|
||||||
Node::Link(uri) => Some(uri.href().to_string()),
|
Node::Link(uri) => Some(uri.href().to_string()),
|
||||||
Node::Object(obj) => Some(obj.id()?.to_string()),
|
Node::Object(obj) => obj.id().map(|x| x.to_string()),
|
||||||
Node::Array(arr) => Some(arr.front()?.id()?.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -134,7 +124,7 @@ impl Node<serde_json::Value> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn array(values: Vec<serde_json::Value>) -> Self {
|
pub fn array(values: Vec<serde_json::Value>) -> Self {
|
||||||
Node::Array(values.into())
|
Node::Array(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "fetch")]
|
#[cfg(feature = "fetch")]
|
||||||
|
@ -175,7 +165,7 @@ impl From<serde_json::Value> for Node<serde_json::Value> {
|
||||||
fn from(value: serde_json::Value) -> Self {
|
fn from(value: serde_json::Value) -> Self {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(uri) => Node::Link(Box::new(uri)),
|
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") {
|
serde_json::Value::Object(_) => match value.get("href") {
|
||||||
None => Node::Object(Box::new(value)),
|
None => Node::Object(Box::new(value)),
|
||||||
Some(_) => Node::Link(Box::new(value)),
|
Some(_) => Node::Link(Box::new(value)),
|
||||||
|
|
|
@ -8,12 +8,9 @@ pub trait Outbox {
|
||||||
async fn create(&self, uid: String, activity: Self::Activity) -> Result<String, Self::Error>;
|
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 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 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 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 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 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]
|
#[async_trait::async_trait]
|
||||||
|
@ -21,13 +18,12 @@ pub trait Inbox {
|
||||||
type Activity: crate::Activity;
|
type Activity: crate::Activity;
|
||||||
type Error: std::error::Error;
|
type Error: std::error::Error;
|
||||||
|
|
||||||
async fn create(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn create(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn like(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn like(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn follow(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn follow(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn announce(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn accept(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn accept(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn reject(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn reject(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn undo(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn undo(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn delete(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn delete(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
async fn update(&self, activity: Self::Activity) -> Result<(), Self::Error>;
|
||||||
async fn update(&self, server: String, activity: Self::Activity) -> Result<(), Self::Error>;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ use crate::{Object, Link};
|
||||||
|
|
||||||
pub const PUBLIC : &str = "https://www.w3.org/ns/activitystreams#Public";
|
pub const PUBLIC : &str = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
|
|
||||||
pub trait Addressed {
|
pub trait Addressed : Object {
|
||||||
fn addressed(&self) -> Vec<String>;
|
fn addressed(&self) -> Vec<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,21 +32,8 @@ impl Base for String {
|
||||||
|
|
||||||
#[cfg(feature = "unstructured")]
|
#[cfg(feature = "unstructured")]
|
||||||
impl Base for serde_json::Value {
|
impl Base for serde_json::Value {
|
||||||
fn base_type(&self) -> Option<BaseType> {
|
crate::getter! { id -> &str }
|
||||||
if self.is_string() {
|
crate::getter! { base_type -> type BaseType }
|
||||||
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())?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "unstructured")]
|
#[cfg(feature = "unstructured")]
|
||||||
|
|
|
@ -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! {
|
crate::strenum! {
|
||||||
pub enum LinkType {
|
pub enum LinkType {
|
||||||
Link,
|
Link,
|
||||||
|
@ -47,10 +37,16 @@ impl Link for String {
|
||||||
impl Link for serde_json::Value {
|
impl Link for serde_json::Value {
|
||||||
// TODO this can fail, but it should never do!
|
// TODO this can fail, but it should never do!
|
||||||
fn href(&self) -> &str {
|
fn href(&self) -> &str {
|
||||||
if self.is_string() {
|
match self {
|
||||||
self.as_str().unwrap_or("")
|
serde_json::Value::String(x) => x,
|
||||||
} else {
|
serde_json::Value::Object(map) =>
|
||||||
self.get("href").map(|x| x.as_str().unwrap_or("")).unwrap_or("")
|
map.get("href")
|
||||||
|
.map(|h| h.as_str().unwrap_or(""))
|
||||||
|
.unwrap_or(""),
|
||||||
|
_ => {
|
||||||
|
tracing::error!("failed getting href on invalid json Link object");
|
||||||
|
""
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,38 +11,6 @@ use offer::OfferType;
|
||||||
use intransitive::IntransitiveActivityType;
|
use intransitive::IntransitiveActivityType;
|
||||||
use ignore::IgnoreType;
|
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! {
|
crate::strenum! {
|
||||||
pub enum ActivityType {
|
pub enum ActivityType {
|
||||||
Activity,
|
Activity,
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
use crate::{Node, Object, ObjectMut};
|
use crate::Node;
|
||||||
|
|
||||||
|
use crate::{Object, ObjectMut, PublicKey};
|
||||||
|
|
||||||
crate::strenum! {
|
crate::strenum! {
|
||||||
pub enum ActorType {
|
pub enum ActorType {
|
||||||
Application,
|
Application,
|
||||||
Group,
|
Group,
|
||||||
Organization,
|
Organization,
|
||||||
Person,
|
Person;
|
||||||
Service;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Actor : Object {
|
pub trait Actor : Object {
|
||||||
type PublicKey : crate::PublicKey;
|
type PublicKey : PublicKey;
|
||||||
type Endpoints : Endpoints;
|
|
||||||
|
|
||||||
fn actor_type(&self) -> Option<ActorType> { None }
|
fn actor_type(&self) -> Option<ActorType> { None }
|
||||||
fn preferred_username(&self) -> Option<&str> { 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 followers(&self) -> Node<Self::Collection> { Node::Empty }
|
||||||
fn liked(&self) -> Node<Self::Collection> { Node::Empty }
|
fn liked(&self) -> Node<Self::Collection> { Node::Empty }
|
||||||
fn streams(&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 }
|
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
|
// idk about this? everyone has it but AP doesn't mention it
|
||||||
fn discoverable(&self) -> Option<bool> { None }
|
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 {
|
pub trait ActorMut : ObjectMut {
|
||||||
type PublicKey : crate::PublicKey;
|
type PublicKey : PublicKey;
|
||||||
type Endpoints : Endpoints;
|
|
||||||
|
|
||||||
fn set_actor_type(self, val: Option<ActorType>) -> Self;
|
fn set_actor_type(self, val: Option<ActorType>) -> Self;
|
||||||
fn set_preferred_username(self, val: Option<&str>) -> 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_followers(self, val: Node<Self::Collection>) -> Self;
|
||||||
fn set_liked(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_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;
|
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;
|
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")]
|
#[cfg(feature = "unstructured")]
|
||||||
impl Actor for serde_json::Value {
|
impl Actor for serde_json::Value {
|
||||||
type PublicKey = serde_json::Value;
|
type PublicKey = serde_json::Value;
|
||||||
type Endpoints = serde_json::Value;
|
|
||||||
|
|
||||||
crate::getter! { actor_type -> type ActorType }
|
crate::getter! { actor_type -> type ActorType }
|
||||||
crate::getter! { preferred_username::preferredUsername -> &str }
|
crate::getter! { preferred_username::preferredUsername -> &str }
|
||||||
|
@ -125,42 +57,16 @@ impl Actor for serde_json::Value {
|
||||||
crate::getter! { liked -> node Self::Collection }
|
crate::getter! { liked -> node Self::Collection }
|
||||||
crate::getter! { streams -> node Self::Collection }
|
crate::getter! { streams -> node Self::Collection }
|
||||||
crate::getter! { public_key::publicKey -> node Self::PublicKey }
|
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 }
|
crate::getter! { discoverable -> bool }
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "unstructured")]
|
fn endpoints(&self) -> Node<<Self as Object>::Object> {
|
||||||
impl Endpoints for serde_json::Value {
|
todo!()
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "unstructured")]
|
#[cfg(feature = "unstructured")]
|
||||||
impl ActorMut for serde_json::Value {
|
impl ActorMut for serde_json::Value {
|
||||||
type PublicKey = serde_json::Value;
|
type PublicKey = serde_json::Value;
|
||||||
type Endpoints = serde_json::Value;
|
|
||||||
|
|
||||||
crate::setter! { actor_type -> type ActorType }
|
crate::setter! { actor_type -> type ActorType }
|
||||||
crate::setter! { preferred_username::preferredUsername -> &str }
|
crate::setter! { preferred_username::preferredUsername -> &str }
|
||||||
|
@ -171,33 +77,10 @@ impl ActorMut for serde_json::Value {
|
||||||
crate::setter! { liked -> node Self::Collection }
|
crate::setter! { liked -> node Self::Collection }
|
||||||
crate::setter! { streams -> node Self::Collection }
|
crate::setter! { streams -> node Self::Collection }
|
||||||
crate::setter! { public_key::publicKey -> node Self::PublicKey }
|
crate::setter! { public_key::publicKey -> node Self::PublicKey }
|
||||||
crate::setter! { endpoints -> node Self::Endpoints }
|
|
||||||
crate::setter! { discoverable -> bool }
|
crate::setter! { discoverable -> bool }
|
||||||
|
|
||||||
#[cfg(feature = "activitypub-miscellaneous-terms")]
|
fn set_endpoints(mut self, _val: Node<<Self as Object>::Object>) -> Self {
|
||||||
crate::setter! { moved_to::movedTo -> node Self::Actor }
|
self.as_object_mut().unwrap().insert("endpoints".to_string(), serde_json::Value::Object(serde_json::Map::default()));
|
||||||
#[cfg(feature = "activitypub-miscellaneous-terms")]
|
self
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,12 @@ pub mod place;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relationship;
|
pub mod relationship;
|
||||||
|
|
||||||
use crate::{Base, BaseMut, Node};
|
use crate::{Base, BaseMut, Link, Node};
|
||||||
|
|
||||||
use actor::ActorType;
|
use actor::{Actor, ActorType};
|
||||||
use document::DocumentType;
|
use document::{Document, DocumentType};
|
||||||
use activity::ActivityType;
|
use activity::ActivityType;
|
||||||
use collection::CollectionType;
|
use collection::{Collection, CollectionType};
|
||||||
|
|
||||||
crate::strenum! {
|
crate::strenum! {
|
||||||
pub enum ObjectType {
|
pub enum ObjectType {
|
||||||
|
@ -33,12 +33,11 @@ crate::strenum! {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Object : Base {
|
pub trait Object : Base {
|
||||||
type Link : crate::Link;
|
type Link : Link;
|
||||||
type Actor : crate::Actor;
|
type Actor : Actor;
|
||||||
type Object : Object;
|
type Object : Object;
|
||||||
type Collection : crate::Collection;
|
type Collection : Collection;
|
||||||
type Document : crate::Document;
|
type Document : Document;
|
||||||
type Activity : crate::Activity;
|
|
||||||
|
|
||||||
fn object_type(&self) -> Option<ObjectType> { None }
|
fn object_type(&self) -> Option<ObjectType> { None }
|
||||||
fn attachment(&self) -> Node<Self::Object> { Node::Empty }
|
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 location(&self) -> Node<Self::Object> { Node::Empty }
|
||||||
fn preview(&self) -> Node<Self::Object> { Node::Empty } // also in link
|
fn preview(&self) -> Node<Self::Object> { Node::Empty } // also in link
|
||||||
fn published(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
|
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 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 start_time(&self) -> Option<chrono::DateTime<chrono::Utc>> { None }
|
||||||
fn summary(&self) -> Option<&str> { None }
|
fn summary(&self) -> Option<&str> { None }
|
||||||
fn tag(&self) -> Node<Self::Object> { Node::Empty }
|
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 url(&self) -> Node<Self::Link> { Node::Empty }
|
||||||
fn to(&self) -> Node<Self::Link> { Node::Empty }
|
fn to(&self) -> Node<Self::Link> { Node::Empty }
|
||||||
fn bto(&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 bcc(&self) -> Node<Self::Link> { Node::Empty }
|
||||||
fn media_type(&self) -> Option<&str> { None } // also in link
|
fn media_type(&self) -> Option<&str> { None } // also in link
|
||||||
fn duration(&self) -> Option<&str> { None } // TODO how to parse xsd:duration ?
|
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 {
|
pub trait ObjectMut : BaseMut {
|
||||||
type Link : crate::Link;
|
type Link : Link;
|
||||||
type Actor : crate::Actor;
|
type Actor : Actor;
|
||||||
type Object : Object;
|
type Object : Object;
|
||||||
type Collection : crate::Collection;
|
type Collection : Collection;
|
||||||
type Document : crate::Document;
|
type Document : Document;
|
||||||
|
|
||||||
fn set_object_type(self, val: Option<ObjectType>) -> Self;
|
fn set_object_type(self, val: Option<ObjectType>) -> Self;
|
||||||
fn set_attachment(self, val: Node<Self::Object>) -> 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_location(self, val: Node<Self::Object>) -> Self;
|
||||||
fn set_preview(self, val: Node<Self::Object>) -> Self; // also in link
|
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_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_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_start_time(self, val: Option<chrono::DateTime<chrono::Utc>>) -> Self;
|
||||||
fn set_summary(self, val: Option<&str>) -> Self;
|
fn set_summary(self, val: Option<&str>) -> Self;
|
||||||
fn set_tag(self, val: Node<Self::Object>) -> 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_url(self, val: Node<Self::Link>) -> Self;
|
||||||
fn set_to(self, val: Node<Self::Link>) -> Self;
|
fn set_to(self, val: Node<Self::Link>) -> Self;
|
||||||
fn set_bto(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_bcc(self, val: Node<Self::Link>) -> Self;
|
||||||
fn set_media_type(self, val: Option<&str>) -> Self; // also in link
|
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 ?
|
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")]
|
#[cfg(feature = "unstructured")]
|
||||||
|
@ -133,8 +111,7 @@ impl Object for serde_json::Value {
|
||||||
type Object = serde_json::Value;
|
type Object = serde_json::Value;
|
||||||
type Document = serde_json::Value;
|
type Document = serde_json::Value;
|
||||||
type Collection = serde_json::Value;
|
type Collection = serde_json::Value;
|
||||||
type Activity = serde_json::Value;
|
|
||||||
|
|
||||||
crate::getter! { object_type -> type ObjectType }
|
crate::getter! { object_type -> type ObjectType }
|
||||||
crate::getter! { attachment -> node <Self as Object>::Object }
|
crate::getter! { attachment -> node <Self as Object>::Object }
|
||||||
crate::getter! { attributed_to::attributedTo -> node Self::Actor }
|
crate::getter! { attributed_to::attributedTo -> node Self::Actor }
|
||||||
|
@ -149,27 +126,19 @@ impl Object for serde_json::Value {
|
||||||
crate::getter! { location -> node <Self as Object>::Object }
|
crate::getter! { location -> node <Self as Object>::Object }
|
||||||
crate::getter! { preview -> node <Self as Object>::Object }
|
crate::getter! { preview -> node <Self as Object>::Object }
|
||||||
crate::getter! { published -> chrono::DateTime<chrono::Utc> }
|
crate::getter! { published -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::getter! { updated -> chrono::DateTime<chrono::Utc> }
|
|
||||||
crate::getter! { replies -> node Self::Collection }
|
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! { start_time::startTime -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::getter! { summary -> &str }
|
crate::getter! { summary -> &str }
|
||||||
crate::getter! { tag -> node <Self as Object>::Object }
|
crate::getter! { tag -> node <Self as Object>::Object }
|
||||||
|
crate::getter! { updated -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::getter! { to -> node Self::Link }
|
crate::getter! { to -> node Self::Link }
|
||||||
crate::getter! { bto -> node Self::Link }
|
crate::getter! { bto -> node Self::Link }
|
||||||
crate::getter! { cc -> node Self::Link }
|
crate::getter! { cc -> node Self::Link }
|
||||||
crate::getter! { bcc -> 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! { duration -> &str }
|
||||||
crate::getter! { url -> node Self::Link }
|
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!!
|
// 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> {
|
fn context(&self) -> Node<<Self as Object>::Object> {
|
||||||
match self.get("context") {
|
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")]
|
#[cfg(feature = "unstructured")]
|
||||||
|
@ -232,25 +173,30 @@ impl ObjectMut for serde_json::Value {
|
||||||
crate::setter! { location -> node <Self as Object>::Object }
|
crate::setter! { location -> node <Self as Object>::Object }
|
||||||
crate::setter! { preview -> node <Self as Object>::Object }
|
crate::setter! { preview -> node <Self as Object>::Object }
|
||||||
crate::setter! { published -> chrono::DateTime<chrono::Utc> }
|
crate::setter! { published -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::setter! { updated -> chrono::DateTime<chrono::Utc> }
|
|
||||||
crate::setter! { replies -> node Self::Collection }
|
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! { start_time::startTime -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::setter! { summary -> &str }
|
crate::setter! { summary -> &str }
|
||||||
crate::setter! { tag -> node <Self as Object>::Object }
|
crate::setter! { tag -> node <Self as Object>::Object }
|
||||||
|
crate::setter! { updated -> chrono::DateTime<chrono::Utc> }
|
||||||
crate::setter! { to -> node Self::Link }
|
crate::setter! { to -> node Self::Link }
|
||||||
crate::setter! { bto -> node Self::Link}
|
crate::setter! { bto -> node Self::Link}
|
||||||
crate::setter! { cc -> node Self::Link }
|
crate::setter! { cc -> node Self::Link }
|
||||||
crate::setter! { bcc -> 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! { duration -> &str }
|
||||||
crate::setter! { url -> node Self::Link }
|
crate::setter! { url -> node Self::Link }
|
||||||
crate::setter! { context -> node <Self as Object>::Object }
|
|
||||||
|
|
||||||
#[cfg(feature = "activitypub-miscellaneous-terms")]
|
// TODO Mastodon doesn't use a "context" field on the object but makes up a new one!!
|
||||||
crate::setter! { sensitive -> bool }
|
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 }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"]
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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(())
|
|
||||||
}
|
|
|
@ -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(())
|
|
||||||
}
|
|
119
src/cli/mod.rs
119
src/cli/mod.rs
|
@ -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?),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(())
|
|
||||||
}
|
|
|
@ -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(())
|
|
||||||
}
|
|
|
@ -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(())
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,37 +1,22 @@
|
||||||
use axum::{http::StatusCode, response::Redirect};
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum UpubError {
|
pub enum UpubError {
|
||||||
#[error("database error: {0:?}")]
|
#[error("database error: {0}")]
|
||||||
Database(#[from] sea_orm::DbErr),
|
Database(#[from] sea_orm::DbErr),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("api returned {0}")]
|
||||||
Status(axum::http::StatusCode),
|
Status(axum::http::StatusCode),
|
||||||
|
|
||||||
#[error("missing field: {0}")]
|
#[error("missing field: {0}")]
|
||||||
Field(#[from] crate::model::FieldError),
|
Field(#[from] crate::model::FieldError),
|
||||||
|
|
||||||
#[error("openssl error: {0:?}")]
|
#[error("openssl error: {0}")]
|
||||||
OpenSSL(#[from] openssl::error::ErrorStack),
|
OpenSSL(#[from] openssl::error::ErrorStack),
|
||||||
|
|
||||||
#[error("invalid UTF8 in key: {0:?}")]
|
#[error("invalid UTF8 in key: {0}")]
|
||||||
OpenSSLParse(#[from] std::str::Utf8Error),
|
OpenSSLParse(#[from] std::str::Utf8Error),
|
||||||
|
|
||||||
#[error("fetch error: {0:?}")]
|
#[error("fetch error: {0}")]
|
||||||
Reqwest(#[from] reqwest::Error),
|
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 {
|
impl UpubError {
|
||||||
|
@ -51,10 +36,6 @@ impl UpubError {
|
||||||
Self::Status(axum::http::StatusCode::FORBIDDEN)
|
Self::Status(axum::http::StatusCode::FORBIDDEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unauthorized() -> Self {
|
|
||||||
Self::Status(axum::http::StatusCode::UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn not_modified() -> Self {
|
pub fn not_modified() -> Self {
|
||||||
Self::Status(axum::http::StatusCode::NOT_MODIFIED)
|
Self::Status(axum::http::StatusCode::NOT_MODIFIED)
|
||||||
}
|
}
|
||||||
|
@ -74,19 +55,10 @@ impl From<axum::http::StatusCode> for UpubError {
|
||||||
|
|
||||||
impl axum::response::IntoResponse for UpubError {
|
impl axum::response::IntoResponse for UpubError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
let descr = self.to_string();
|
(
|
||||||
match self {
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
UpubError::Redirect(to) => Redirect::to(&to).into_response(),
|
self.to_string()
|
||||||
UpubError::Status(status) => (status, descr).into_response(),
|
).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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
145
src/main.rs
145
src/main.rs
|
@ -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
|
pub mod server;
|
||||||
mod model;
|
pub mod model;
|
||||||
mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
mod errors;
|
pub mod tools;
|
||||||
mod config;
|
pub mod errors;
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
|
||||||
mod cli;
|
|
||||||
|
|
||||||
#[cfg(feature = "migrations")]
|
#[cfg(feature = "migrations")]
|
||||||
mod migrations;
|
mod migrations;
|
||||||
|
|
||||||
#[cfg(feature = "migrations")]
|
#[cfg(feature = "migrations")]
|
||||||
use sea_orm_migration::MigratorTrait;
|
use sea_orm_migration::MigratorTrait;
|
||||||
use std::path::PathBuf;
|
|
||||||
use config::Config;
|
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use sea_orm::{ConnectOptions, Database};
|
use sea_orm::{ConnectOptions, Database, EntityTrait, IntoActiveModel};
|
||||||
|
|
||||||
pub use errors::UpubResult as Result;
|
pub use errors::UpubResult as Result;
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
|
||||||
|
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
/// all names were taken
|
/// all names were taken
|
||||||
struct Args {
|
struct CliArgs {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
/// command to run
|
/// command to run
|
||||||
command: Mode,
|
command: CliCommand,
|
||||||
|
|
||||||
/// path to config file, leave empty to not use any
|
#[arg(short = 'd', long = "db", default_value = "sqlite://./upub.db")]
|
||||||
#[arg(short, long)]
|
/// database connection uri
|
||||||
config: Option<PathBuf>,
|
database: String,
|
||||||
|
|
||||||
#[arg(long = "db")]
|
#[arg(short = 'D', long, default_value = "http://localhost:3000")]
|
||||||
/// database connection uri, overrides config value
|
/// instance base domain, for AP ids
|
||||||
database: Option<String>,
|
domain: String,
|
||||||
|
|
||||||
#[arg(long)]
|
|
||||||
/// instance base domain, for AP ids, overrides config value
|
|
||||||
domain: Option<String>,
|
|
||||||
|
|
||||||
#[arg(long, default_value_t=false)]
|
#[arg(long, default_value_t=false)]
|
||||||
/// run with debug level tracing
|
/// run with debug level tracing
|
||||||
|
@ -49,73 +39,65 @@ struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Subcommand)]
|
#[derive(Clone, Subcommand)]
|
||||||
enum Mode {
|
enum CliCommand {
|
||||||
/// run fediverse server
|
/// run fediverse server
|
||||||
Serve,
|
Serve ,
|
||||||
|
|
||||||
/// print current or default configuration
|
|
||||||
Config,
|
|
||||||
|
|
||||||
#[cfg(feature = "migrations")]
|
#[cfg(feature = "migrations")]
|
||||||
/// apply database migrations
|
/// apply database migrations
|
||||||
Migrate,
|
Migrate,
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "faker")]
|
||||||
/// run maintenance CLI tasks
|
/// generate fake user, note and activity
|
||||||
Cli {
|
Faker{
|
||||||
#[clap(subcommand)]
|
/// how many fake statuses to insert for root user
|
||||||
/// task to run
|
count: u64,
|
||||||
command: cli::CliCommand,
|
},
|
||||||
|
|
||||||
|
/// 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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = CliArgs::parse();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.compact()
|
.compact()
|
||||||
.with_max_level(if args.debug { tracing::Level::DEBUG } else { tracing::Level::INFO })
|
.with_max_level(if args.debug { tracing::Level::DEBUG } else { tracing::Level::INFO })
|
||||||
.init();
|
.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?
|
// 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
|
opts
|
||||||
.sqlx_logging_level(tracing::log::LevelFilter::Debug)
|
.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)
|
|
||||||
);
|
|
||||||
|
|
||||||
let db = Database::connect(opts)
|
let db = Database::connect(opts)
|
||||||
.await.expect("error connecting to db");
|
.await.expect("error connecting to db");
|
||||||
|
|
||||||
match args.command {
|
match args.command {
|
||||||
#[cfg(feature = "migrations")]
|
#[cfg(feature = "migrations")]
|
||||||
Mode::Migrate =>
|
CliCommand::Migrate => migrations::Migrator::up(&db, None)
|
||||||
migrations::Migrator::up(&db, None)
|
.await.expect("error applying migrations"),
|
||||||
.await.expect("error applying migrations"),
|
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "faker")]
|
||||||
Mode::Cli { command } =>
|
CliCommand::Faker { count } => model::faker::faker(&db, args.domain, count)
|
||||||
cli::run(command, db, domain, config)
|
.await.expect("error creating fake entities"),
|
||||||
.await.expect("failed running cli task"),
|
|
||||||
|
|
||||||
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 => {
|
CliCommand::Serve => {
|
||||||
let ctx = server::Context::new(db, domain, config)
|
let ctx = server::Context::new(db, args.domain)
|
||||||
.await.expect("failed creating server context");
|
.await.expect("failed creating server context");
|
||||||
|
|
||||||
use routes::activitypub::ActivityPubRouter;
|
use routes::activitypub::ActivityPubRouter;
|
||||||
|
@ -124,8 +106,6 @@ async fn main() {
|
||||||
let router = axum::Router::new()
|
let router = axum::Router::new()
|
||||||
.ap_routes()
|
.ap_routes()
|
||||||
.mastodon_routes() // no-op if mastodon feature is disabled
|
.mastodon_routes() // no-op if mastodon feature is disabled
|
||||||
.layer(CorsLayer::permissive())
|
|
||||||
.layer(TraceLayer::new_for_http())
|
|
||||||
.with_state(ctx);
|
.with_state(ctx);
|
||||||
|
|
||||||
// run our app with hyper, listening locally on port 3000
|
// 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(())
|
||||||
|
}
|
||||||
|
|
|
@ -18,11 +18,11 @@ impl MigrationTrait for Migration {
|
||||||
)
|
)
|
||||||
.col(ColumnDef::new(Users::ActorType).string().not_null())
|
.col(ColumnDef::new(Users::ActorType).string().not_null())
|
||||||
.col(ColumnDef::new(Users::Domain).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::Summary).string().null())
|
||||||
.col(ColumnDef::new(Users::Image).string().null())
|
.col(ColumnDef::new(Users::Image).string().null())
|
||||||
.col(ColumnDef::new(Users::Icon).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::Inbox).string().null())
|
||||||
.col(ColumnDef::new(Users::SharedInbox).string().null())
|
.col(ColumnDef::new(Users::SharedInbox).string().null())
|
||||||
.col(ColumnDef::new(Users::Outbox).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::Followers).string().null())
|
||||||
.col(ColumnDef::new(Users::FollowingCount).integer().not_null().default(0))
|
.col(ColumnDef::new(Users::FollowingCount).integer().not_null().default(0))
|
||||||
.col(ColumnDef::new(Users::FollowersCount).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::PublicKey).string().not_null())
|
||||||
.col(ColumnDef::new(Users::PrivateKey).string().null())
|
.col(ColumnDef::new(Users::PrivateKey).string().null())
|
||||||
.col(ColumnDef::new(Users::Created).date_time().not_null())
|
.col(ColumnDef::new(Users::Created).date_time().not_null())
|
||||||
|
@ -126,7 +125,6 @@ enum Users {
|
||||||
FollowingCount,
|
FollowingCount,
|
||||||
Followers,
|
Followers,
|
||||||
FollowersCount,
|
FollowersCount,
|
||||||
// StatusesCount,
|
|
||||||
PublicKey,
|
PublicKey,
|
||||||
PrivateKey,
|
PrivateKey,
|
||||||
Created,
|
Created,
|
||||||
|
|
|
@ -19,7 +19,7 @@ impl MigrationTrait for Migration {
|
||||||
)
|
)
|
||||||
.col(ColumnDef::new(Addressing::Actor).string().not_null())
|
.col(ColumnDef::new(Addressing::Actor).string().not_null())
|
||||||
.col(ColumnDef::new(Addressing::Server).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::Object).string().null())
|
||||||
.col(ColumnDef::new(Addressing::Published).date_time().not_null())
|
.col(ColumnDef::new(Addressing::Published).date_time().not_null())
|
||||||
.to_owned()
|
.to_owned()
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,12 +9,6 @@ mod m20240323_000002_add_simple_credentials;
|
||||||
mod m20240324_000001_add_addressing;
|
mod m20240324_000001_add_addressing;
|
||||||
mod m20240325_000001_add_deliveries;
|
mod m20240325_000001_add_deliveries;
|
||||||
mod m20240325_000002_add_system_key;
|
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;
|
pub struct Migrator;
|
||||||
|
|
||||||
|
@ -31,12 +25,6 @@ impl MigratorTrait for Migrator {
|
||||||
Box::new(m20240324_000001_add_addressing::Migration),
|
Box::new(m20240324_000001_add_addressing::Migration),
|
||||||
Box::new(m20240325_000001_add_deliveries::Migration),
|
Box::new(m20240325_000001_add_deliveries::Migration),
|
||||||
Box::new(m20240325_000002_add_system_key::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),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
use apb::{ActivityMut, BaseMut, ObjectMut};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
use crate::routes::activitypub::jsonld::LD;
|
|
||||||
|
|
||||||
use super::Audience;
|
use super::Audience;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
@ -40,20 +37,6 @@ impl Model {
|
||||||
bcc: activity.bcc().into(),
|
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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@ -98,13 +81,3 @@ impl Related<super::addressing::Entity> for Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
use apb::{ActivityMut, ObjectMut};
|
use sea_orm::entity::prelude::*;
|
||||||
use sea_orm::{entity::prelude::*, sea_query::IntoCondition, Condition, FromQueryResult, Iterable, Order, QueryOrder, QuerySelect, SelectColumns};
|
|
||||||
|
|
||||||
use crate::routes::activitypub::jsonld::LD;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "addressing")]
|
#[sea_orm(table_name = "addressing")]
|
||||||
|
@ -10,7 +7,7 @@ pub struct Model {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub actor: String,
|
pub actor: String,
|
||||||
pub server: String,
|
pub server: String,
|
||||||
pub activity: Option<String>,
|
pub activity: String,
|
||||||
pub object: Option<String>,
|
pub object: Option<String>,
|
||||||
pub published: ChronoDateTimeUtc,
|
pub published: ChronoDateTimeUtc,
|
||||||
}
|
}
|
||||||
|
@ -58,117 +55,3 @@ impl Related<super::object::Entity> for Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 openssl::rsa::Rsa;
|
||||||
use sea_orm::IntoActiveModel;
|
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};
|
use sea_orm::{EntityTrait, Set};
|
||||||
|
|
||||||
let domain = ctx.domain();
|
|
||||||
let db = ctx.db();
|
|
||||||
|
|
||||||
let key = Rsa::generate(2048).unwrap();
|
let key = Rsa::generate(2048).unwrap();
|
||||||
let test_user = user::Model {
|
let test_user = super::user::Model {
|
||||||
id: format!("{domain}/users/test"),
|
id: format!("{domain}/users/test"),
|
||||||
name: Some("μpub".into()),
|
name: Some("μpub".into()),
|
||||||
domain: clean_domain(domain),
|
domain: clean_domain(&domain),
|
||||||
preferred_username: "test".to_string(),
|
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()),
|
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: None,
|
||||||
following_count: 0,
|
following_count: 0,
|
||||||
followers: None,
|
followers: None,
|
||||||
followers_count: 0,
|
followers_count: 0,
|
||||||
statuses_count: count as i64,
|
|
||||||
icon: Some("https://cdn.alemi.dev/social/circle-square.png".to_string()),
|
icon: Some("https://cdn.alemi.dev/social/circle-square.png".to_string()),
|
||||||
image: Some("https://cdn.alemi.dev/social/someriver-xs.jpg".to_string()),
|
image: Some("https://cdn.alemi.dev/social/someriver-xs.jpg".to_string()),
|
||||||
inbox: None,
|
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() {
|
for i in (0..count).rev() {
|
||||||
let oid = uuid::Uuid::new_v4();
|
let oid = uuid::Uuid::new_v4();
|
||||||
let aid = 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 {
|
object::Entity::insert(object::ActiveModel {
|
||||||
id: Set(format!("{domain}/objects/{oid}")),
|
id: Set(format!("{domain}/objects/{oid}")),
|
||||||
name: Set(None),
|
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"))),
|
attributed_to: Set(Some(format!("{domain}/users/test"))),
|
||||||
summary: Set(None),
|
summary: Set(None),
|
||||||
context: Set(Some(context.clone())),
|
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."))),
|
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)),
|
published: Set(chrono::Utc::now() - std::time::Duration::from_secs(60*i)),
|
||||||
updated: Set(None),
|
|
||||||
comments: Set(0),
|
comments: Set(0),
|
||||||
likes: Set(0),
|
likes: Set(0),
|
||||||
shares: 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()),
|
bto: Set(Audience::default()),
|
||||||
cc: Set(Audience(vec![])),
|
cc: Set(Audience(vec![])),
|
||||||
bcc: Set(Audience::default()),
|
bcc: Set(Audience::default()),
|
||||||
url: Set(None),
|
|
||||||
sensitive: Set(false),
|
|
||||||
}).exec(db).await?;
|
}).exec(db).await?;
|
||||||
|
|
||||||
activity::Entity::insert(activity::ActiveModel {
|
activity::Entity::insert(activity::ActiveModel {
|
|
@ -11,19 +11,6 @@ pub struct Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
|
@ -3,7 +3,6 @@ pub mod activity;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|
||||||
pub mod relay;
|
|
||||||
pub mod relation;
|
pub mod relation;
|
||||||
pub mod addressing;
|
pub mod addressing;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
@ -11,9 +10,11 @@ pub mod like;
|
||||||
pub mod credential;
|
pub mod credential;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod delivery;
|
pub mod delivery;
|
||||||
pub mod attachment;
|
|
||||||
pub mod application;
|
pub mod application;
|
||||||
|
|
||||||
|
#[cfg(feature = "faker")]
|
||||||
|
pub mod faker;
|
||||||
|
|
||||||
#[derive(Debug, Clone, thiserror::Error)]
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
#[error("missing required field: '{0}'")]
|
#[error("missing required field: '{0}'")]
|
||||||
pub struct FieldError(pub &'static str);
|
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)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, sea_orm::FromJsonQueryResult)]
|
||||||
pub struct Audience(pub Vec<String>);
|
pub struct Audience(pub Vec<String>);
|
||||||
|
|
||||||
impl<T: apb::Base> From<apb::Node<T>> for Audience {
|
use apb::{Link, Node};
|
||||||
fn from(value: apb::Node<T>) -> Self {
|
impl<T : Link> From<Node<T>> for Audience {
|
||||||
|
fn from(value: Node<T>) -> Self {
|
||||||
Audience(
|
Audience(
|
||||||
match value {
|
match value {
|
||||||
apb::Node::Empty => vec![],
|
Node::Empty => vec![],
|
||||||
apb::Node::Link(l) => vec![l.href().to_string()],
|
Node::Link(l) => vec![l.href().to_string()],
|
||||||
apb::Node::Object(o) => if let Some(id) = o.id() { vec![id.to_string()] } else { vec![] },
|
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::Array(arr) => arr.into_iter().filter_map(|l| Some(l.id()?.to_string())).collect(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
use apb::{BaseMut, Collection, CollectionMut, ObjectMut};
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
use crate::routes::activitypub::jsonld::LD;
|
|
||||||
|
|
||||||
use super::Audience;
|
use super::Audience;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
@ -19,16 +16,11 @@ pub struct Model {
|
||||||
pub shares: i64,
|
pub shares: i64,
|
||||||
pub comments: i64,
|
pub comments: i64,
|
||||||
pub context: Option<String>,
|
pub context: Option<String>,
|
||||||
pub in_reply_to: Option<String>,
|
|
||||||
pub cc: Audience,
|
pub cc: Audience,
|
||||||
pub bcc: Audience,
|
pub bcc: Audience,
|
||||||
pub to: Audience,
|
pub to: Audience,
|
||||||
pub bto: Audience,
|
pub bto: Audience,
|
||||||
pub url: Option<String>,
|
|
||||||
pub published: ChronoDateTimeUtc,
|
pub published: ChronoDateTimeUtc,
|
||||||
pub updated: Option<ChronoDateTimeUtc>,
|
|
||||||
|
|
||||||
pub sensitive: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
|
@ -41,59 +33,16 @@ impl Model {
|
||||||
summary: object.summary().map(|x| x.to_string()),
|
summary: object.summary().map(|x| x.to_string()),
|
||||||
content: object.content().map(|x| x.to_string()),
|
content: object.content().map(|x| x.to_string()),
|
||||||
context: object.context().id(),
|
context: object.context().id(),
|
||||||
in_reply_to: object.in_reply_to().id(),
|
|
||||||
published: object.published().ok_or(super::FieldError("published"))?,
|
published: object.published().ok_or(super::FieldError("published"))?,
|
||||||
updated: object.updated(),
|
comments: 0,
|
||||||
url: object.url().id(),
|
likes: 0,
|
||||||
comments: object.replies().get()
|
shares: 0,
|
||||||
.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,
|
|
||||||
to: object.to().into(),
|
to: object.to().into(),
|
||||||
bto: object.bto().into(),
|
bto: object.bto().into(),
|
||||||
cc: object.cc().into(),
|
cc: object.cc().into(),
|
||||||
bcc: object.bcc().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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
@ -110,15 +59,6 @@ pub enum Relation {
|
||||||
|
|
||||||
#[sea_orm(has_many = "super::addressing::Entity")]
|
#[sea_orm(has_many = "super::addressing::Entity")]
|
||||||
Addressing,
|
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 {
|
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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 {}
|
|
|
@ -11,19 +11,6 @@ pub struct Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
use apb::{Actor, ActorMut, ActorType, BaseMut, DocumentMut, Endpoints, EndpointsMut, Object, ObjectMut, PublicKey, PublicKeyMut};
|
use apb::{Collection, Actor, PublicKey, ActorType};
|
||||||
|
|
||||||
use crate::routes::activitypub::jsonld::LD;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "users")]
|
#[sea_orm(table_name = "users")]
|
||||||
|
@ -26,7 +24,6 @@ pub struct Model {
|
||||||
|
|
||||||
pub following_count: i64,
|
pub following_count: i64,
|
||||||
pub followers_count: i64,
|
pub followers_count: i64,
|
||||||
pub statuses_count: i64,
|
|
||||||
|
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
pub private_key: Option<String>,
|
pub private_key: Option<String>,
|
||||||
|
@ -42,68 +39,27 @@ pub struct Model {
|
||||||
impl Model {
|
impl Model {
|
||||||
pub fn new(object: &impl Actor) -> Result<Self, super::FieldError> {
|
pub fn new(object: &impl Actor) -> Result<Self, super::FieldError> {
|
||||||
let ap_id = object.id().ok_or(super::FieldError("id"))?.to_string();
|
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 {
|
Ok(Model {
|
||||||
id: ap_id,
|
id: ap_id, preferred_username, domain,
|
||||||
domain,
|
|
||||||
preferred_username: object.preferred_username().unwrap_or(&fallback_preferred_username).to_string(),
|
|
||||||
actor_type: object.actor_type().ok_or(super::FieldError("type"))?,
|
actor_type: object.actor_type().ok_or(super::FieldError("type"))?,
|
||||||
name: object.name().map(|x| x.to_string()),
|
name: object.name().map(|x| x.to_string()),
|
||||||
summary: object.summary().map(|x| x.to_string()),
|
summary: object.summary().map(|x| x.to_string()),
|
||||||
icon: object.icon().get().and_then(|x| x.url().id()),
|
icon: object.icon().id(),
|
||||||
image: object.image().get().and_then(|x| x.url().id()),
|
image: object.image().id(),
|
||||||
inbox: object.inbox().id(),
|
inbox: object.inbox().id(),
|
||||||
outbox: object.outbox().id(),
|
outbox: object.inbox().id(),
|
||||||
shared_inbox: object.endpoints().get().and_then(|x| Some(x.shared_inbox()?.to_string())),
|
shared_inbox: None, // TODO!!! parse endpoints
|
||||||
followers: object.followers().id(),
|
followers: object.followers().id(),
|
||||||
following: object.following().id(),
|
following: object.following().id(),
|
||||||
created: object.published().unwrap_or(chrono::Utc::now()),
|
created: object.published().unwrap_or(chrono::Utc::now()),
|
||||||
updated: chrono::Utc::now(),
|
updated: chrono::Utc::now(),
|
||||||
following_count: object.following_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_count().unwrap_or(0) as i64,
|
followers_count: object.followers().get().map(|f| f.total_items().unwrap_or(0)).unwrap_or(0) as i64,
|
||||||
statuses_count: object.statuses_count().unwrap_or(0) as i64,
|
|
||||||
public_key: object.public_key().get().ok_or(super::FieldError("publicKey"))?.public_key_pem().to_string(),
|
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
|
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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|
|
@ -1,34 +1,43 @@
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::{extract::{Path, State}, http::StatusCode};
|
||||||
use sea_orm::{ColumnTrait, QueryFilter};
|
use sea_orm::EntityTrait;
|
||||||
use crate::{errors::UpubError, model::{self, addressing::Event, attachment::BatchFillable}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}};
|
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(
|
pub fn ap_activity(activity: model::activity::Model) -> serde_json::Value {
|
||||||
State(ctx): State<Context>,
|
serde_json::Value::new_object()
|
||||||
Path(id): Path<String>,
|
.set_id(Some(&activity.id))
|
||||||
AuthIdentity(auth): AuthIdentity,
|
.set_activity_type(Some(activity.activity_type))
|
||||||
Query(query): Query<TryFetch>,
|
.set_actor(Node::link(activity.actor))
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
.set_object(Node::maybe_link(activity.object))
|
||||||
let aid = ctx.uri("activities", id);
|
.set_target(Node::maybe_link(activity.target))
|
||||||
if auth.is_local() && query.fetch && !ctx.is_local(&aid) {
|
.set_published(Some(activity.published))
|
||||||
let obj = ctx.fetch_activity(&aid).await?;
|
.set_to(Node::links(activity.to.0.clone()))
|
||||||
if obj.id != aid {
|
.set_bto(Node::Empty)
|
||||||
return Err(UpubError::Redirect(obj.id));
|
.set_cc(Node::links(activity.cc.0.clone()))
|
||||||
}
|
.set_bcc(Node::Empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = model::addressing::Entity::find_addressed(auth.my_id())
|
pub async fn view(State(ctx) : State<Context>, Path(id): Path<String>) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
.filter(model::activity::Column::Id.eq(&aid))
|
match activity::Entity::find_by_id(ctx.aid(id))
|
||||||
.filter(auth.filter_condition())
|
.find_also_related(object::Entity)
|
||||||
.into_model::<Event>()
|
.one(ctx.db())
|
||||||
.one(ctx.db())
|
.await
|
||||||
.await?
|
{
|
||||||
.ok_or_else(UpubError::not_found)?;
|
Ok(Some((activity, Some(object)))) => Ok(JsonLD(
|
||||||
|
ap_activity(activity)
|
||||||
let mut attachments = row.load_attachments_batch(ctx.db()).await?;
|
.set_object(Node::object(super::object::ap_object(object)))
|
||||||
let attach = attachments.remove(row.id());
|
.ld_context()
|
||||||
|
)),
|
||||||
Ok(JsonLD(row.ap(attach).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)
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,34 +1,19 @@
|
||||||
use apb::{ActorMut, BaseMut, ObjectMut, PublicKeyMut};
|
use apb::{ActorMut, BaseMut, ObjectMut, PublicKeyMut};
|
||||||
use axum::{extract::{Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
use reqwest::Method;
|
|
||||||
|
|
||||||
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(
|
pub async fn view(State(ctx): State<Context>) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
headers: HeaderMap,
|
Ok(Json(
|
||||||
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(
|
|
||||||
serde_json::Value::new_object()
|
serde_json::Value::new_object()
|
||||||
.set_id(Some(&url!(ctx, "")))
|
.set_id(Some(&url!(ctx, "")))
|
||||||
.set_actor_type(Some(apb::ActorType::Application))
|
.set_actor_type(Some(apb::ActorType::Application))
|
||||||
.set_name(Some(&ctx.cfg().instance.name))
|
.set_name(Some("μpub"))
|
||||||
.set_summary(Some(&ctx.cfg().instance.description))
|
.set_summary(Some("micro social network, federated"))
|
||||||
.set_inbox(apb::Node::link(url!(ctx, "/inbox")))
|
|
||||||
.set_outbox(apb::Node::link(url!(ctx, "/outbox")))
|
|
||||||
.set_published(Some(ctx.app().created))
|
.set_published(Some(ctx.app().created))
|
||||||
.set_endpoints(apb::Node::Empty)
|
|
||||||
.set_preferred_username(Some(ctx.domain()))
|
|
||||||
.set_public_key(apb::Node::object(
|
.set_public_key(apb::Node::object(
|
||||||
serde_json::Value::new_object()
|
serde_json::Value::new_object()
|
||||||
.set_id(Some(&url!(ctx, "#main-key")))
|
.set_id(Some(&url!(ctx, "#main-key")))
|
||||||
|
@ -36,34 +21,5 @@ pub async fn view(
|
||||||
.set_public_key_pem(&ctx.app().public_key)
|
.set_public_key_pem(&ctx.app().public_key)
|
||||||
))
|
))
|
||||||
.ld_context()
|
.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?
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ use axum::{http::StatusCode, extract::State, Json};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
|
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)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
@ -11,17 +11,7 @@ pub struct LoginForm {
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
pub async fn login(State(ctx): State<Context>, Json(login): Json<LoginForm>) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
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>> {
|
|
||||||
// TODO salt the pwd
|
// TODO salt the pwd
|
||||||
match model::credential::Entity::find()
|
match model::credential::Entity::find()
|
||||||
.filter(Condition::all()
|
.filter(Condition::all()
|
||||||
|
@ -29,60 +19,30 @@ pub async fn login(
|
||||||
.add(model::credential::Column::Password.eq(sha256::digest(login.password)))
|
.add(model::credential::Column::Password.eq(sha256::digest(login.password)))
|
||||||
)
|
)
|
||||||
.one(ctx.db())
|
.one(ctx.db())
|
||||||
.await?
|
.await
|
||||||
{
|
{
|
||||||
Some(x) => {
|
Ok(Some(x)) => {
|
||||||
// TODO should probably use crypto-safe rng
|
// TODO should probably use crypto-safe rng
|
||||||
let token : String = rand::thread_rng()
|
let token : String = rand::thread_rng()
|
||||||
.sample_iter(&rand::distributions::Alphanumeric)
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
.take(128)
|
.take(128)
|
||||||
.map(char::from)
|
.map(char::from)
|
||||||
.collect();
|
.collect();
|
||||||
let expires = chrono::Utc::now() + std::time::Duration::from_secs(3600 * 6);
|
|
||||||
model::session::Entity::insert(
|
model::session::Entity::insert(
|
||||||
model::session::ActiveModel {
|
model::session::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::Set(token.clone()),
|
id: sea_orm::ActiveValue::Set(token.clone()),
|
||||||
actor: sea_orm::ActiveValue::Set(x.id.clone()),
|
actor: sea_orm::ActiveValue::Set(x.id),
|
||||||
expires: sea_orm::ActiveValue::Set(expires),
|
expires: sea_orm::ActiveValue::Set(chrono::Utc::now() + std::time::Duration::from_secs(3600 * 6)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.exec(ctx.db())
|
.exec(ctx.db())
|
||||||
.await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(AuthSuccess {
|
Ok(Json(serde_json::Value::String(token)))
|
||||||
token, expires,
|
|
||||||
user: x.id
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
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)))
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,96 +1,46 @@
|
||||||
use apb::{server::Inbox, Activity, ActivityType};
|
use axum::{extract::{Query, State}, http::StatusCode};
|
||||||
use axum::{extract::{Query, State}, http::StatusCode, Json};
|
use sea_orm::{ColumnTrait, Condition, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use sea_orm::{sea_query::IntoCondition, ColumnTrait};
|
|
||||||
|
|
||||||
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(
|
pub async fn get(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
crate::server::builders::collection(&url!(ctx, "/inbox"), None)
|
Ok(JsonLD(ctx.ap_collection(&url!(ctx, "/inbox"), None).ld_context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn page(
|
pub async fn page(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
AuthIdentity(auth): AuthIdentity,
|
AuthIdentity(auth): AuthIdentity,
|
||||||
Query(page): Query<Pagination>,
|
Query(page): Query<Pagination>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, UpubError> {
|
||||||
crate::server::builders::paginate(
|
let limit = page.batch.unwrap_or(20).min(50);
|
||||||
url!(ctx, "/inbox/page"),
|
let offset = page.offset.unwrap_or(0);
|
||||||
crate::model::addressing::Column::Actor.eq(apb::target::PUBLIC)
|
let mut condition = Condition::any()
|
||||||
.into_condition(),
|
.add(model::addressing::Column::Actor.eq(apb::target::PUBLIC));
|
||||||
ctx.db(),
|
if let Identity::Local(user) = auth {
|
||||||
page,
|
condition = condition
|
||||||
auth.my_id(),
|
.add(model::addressing::Column::Actor.eq(user));
|
||||||
)
|
|
||||||
.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())
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
pub trait LD {
|
pub trait LD {
|
||||||
|
@ -15,31 +9,11 @@ pub trait LD {
|
||||||
|
|
||||||
impl LD for serde_json::Value {
|
impl LD for serde_json::Value {
|
||||||
fn ld_context(mut self) -> Self {
|
fn ld_context(mut self) -> Self {
|
||||||
let o_type = self.object_type();
|
|
||||||
if let Some(obj) = self.as_object_mut() {
|
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(
|
obj.insert(
|
||||||
"@context".to_string(),
|
"@context".to_string(),
|
||||||
serde_json::Value::Array(vec![
|
serde_json::Value::Array(vec![
|
||||||
serde_json::Value::String("https://www.w3.org/ns/activitystreams".into()),
|
serde_json::Value::String("https://www.w3.org/ns/activitystreams".into())
|
||||||
serde_json::Value::Object(ctx),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,6 @@ pub mod user;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
pub mod object;
|
pub mod object;
|
||||||
pub mod context;
|
|
||||||
pub mod activity;
|
pub mod activity;
|
||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
@ -11,7 +10,7 @@ pub mod well_known;
|
||||||
pub mod jsonld;
|
pub mod jsonld;
|
||||||
pub use jsonld::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 {
|
pub trait ActivityPubRouter {
|
||||||
fn ap_routes(self) -> Self;
|
fn ap_routes(self) -> Self;
|
||||||
|
@ -24,23 +23,17 @@ impl ActivityPubRouter for Router<crate::server::Context> {
|
||||||
self
|
self
|
||||||
// core server inbox/outbox, maybe for feeds? TODO do we need these?
|
// core server inbox/outbox, maybe for feeds? TODO do we need these?
|
||||||
.route("/", get(ap::application::view))
|
.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
|
// 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", get(ap::inbox::get))
|
||||||
.route("/inbox/page", get(ap::inbox::page))
|
// .route("/inbox", post(ap::inbox::post))
|
||||||
.route("/outbox", post(ap::outbox::post))
|
// .route("/outbox", get(ap::outbox::get))
|
||||||
.route("/outbox", get(ap::outbox::get))
|
// .route("/outbox", get(ap::outbox::post))
|
||||||
.route("/outbox/page", get(ap::outbox::page))
|
|
||||||
// AUTH routes
|
// AUTH routes
|
||||||
.route("/auth", post(ap::auth::login))
|
.route("/auth", post(ap::auth::login))
|
||||||
.route("/auth", put(ap::auth::register))
|
|
||||||
// .well-known and discovery
|
// .well-known and discovery
|
||||||
.route("/.well-known/webfinger", get(ap::well_known::webfinger))
|
.route("/.well-known/webfinger", get(ap::well_known::webfinger))
|
||||||
.route("/.well-known/host-meta", get(ap::well_known::host_meta))
|
.route("/.well-known/host-meta", get(ap::well_known::host_meta))
|
||||||
.route("/.well-known/nodeinfo", get(ap::well_known::nodeinfo_discovery))
|
.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))
|
.route("/nodeinfo/:version", get(ap::well_known::nodeinfo))
|
||||||
// actor routes
|
// actor routes
|
||||||
.route("/users/:id", get(ap::user::view))
|
.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/followers/page", get(ap::user::following::page::<false>))
|
||||||
.route("/users/:id/following", get(ap::user::following::get::<true>))
|
.route("/users/:id/following", get(ap::user::following::get::<true>))
|
||||||
.route("/users/:id/following/page", get(ap::user::following::page::<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
|
// specific object routes
|
||||||
|
.route("/activities/:id", get(ap::activity::view))
|
||||||
.route("/objects/:id", get(ap::object::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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
// TODO i don't really like how pleroma/mastodon do it actually, maybe change this?
|
// TODO i don't really like how pleroma/mastodon do it actually, maybe change this?
|
||||||
pub struct Pagination {
|
pub struct Pagination {
|
||||||
|
|
34
src/routes/activitypub/object.rs
Normal file
34
src/routes/activitypub/object.rs
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()))
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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())
|
|
||||||
}
|
|
|
@ -1,47 +1,59 @@
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::{extract::{Path, Query, State}, http::StatusCode};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
|
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};
|
use model::relation::Column::{Following, Follower};
|
||||||
|
|
||||||
pub async fn get<const OUTGOING: bool>(
|
pub async fn get<const OUTGOING: bool>(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
let follow___ = if OUTGOING { "following" } else { "followers" };
|
let follow___ = if OUTGOING { "following" } else { "followers" };
|
||||||
let count = model::relation::Entity::find()
|
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| {
|
.count(ctx.db()).await.unwrap_or_else(|e| {
|
||||||
tracing::error!("failed counting {follow___} for {id}: {e}");
|
tracing::error!("failed counting {follow___} for {id}: {e}");
|
||||||
0
|
0
|
||||||
});
|
});
|
||||||
|
Ok(JsonLD(
|
||||||
crate::server::builders::collection(&url!(ctx, "/users/{id}/{follow___}"), Some(count))
|
ctx.ap_collection(
|
||||||
|
&url!(ctx, "/users/{id}/{follow___}"),
|
||||||
|
Some(count)
|
||||||
|
).ld_context()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn page<const OUTGOING: bool>(
|
pub async fn page<const OUTGOING: bool>(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(page): Query<Pagination>,
|
Query(page): Query<Pagination>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
let follow___ = if OUTGOING { "following" } else { "followers" };
|
let follow___ = if OUTGOING { "following" } else { "followers" };
|
||||||
let limit = page.batch.unwrap_or(20).min(50);
|
let limit = page.batch.unwrap_or(20).min(50);
|
||||||
let offset = page.offset.unwrap_or(0);
|
let offset = page.offset.unwrap_or(0);
|
||||||
|
match model::relation::Entity::find()
|
||||||
let following = model::relation::Entity::find()
|
.filter(Condition::all().add(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone()))))
|
||||||
.filter(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone())))
|
|
||||||
.select_only()
|
.select_only()
|
||||||
.select_column(if OUTGOING { Following } else { Follower })
|
.select_column(if OUTGOING { Following } else { Follower })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(page.offset.unwrap_or(0))
|
.offset(page.offset.unwrap_or(0))
|
||||||
.into_tuple::<String>()
|
.into_tuple::<String>()
|
||||||
.all(ctx.db())
|
.all(ctx.db()).await
|
||||||
.await?;
|
{
|
||||||
|
Err(e) => {
|
||||||
crate::server::builders::collection_page(
|
tracing::error!("error queriying {follow___} for {id}: {e}");
|
||||||
&url!(ctx, "/users/{id}/{follow___}/page"),
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
offset, limit,
|
},
|
||||||
following.into_iter().map(serde_json::Value::String).collect()
|
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()
|
||||||
|
))
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,21 @@
|
||||||
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
|
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 apb::{server::Inbox, ActivityMut, ActivityType, Base, BaseType, ObjectType};
|
||||||
use crate::{errors::UpubError, model, routes::activitypub::{JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, url};
|
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(
|
pub async fn get(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
AuthIdentity(auth): AuthIdentity,
|
AuthIdentity(auth): AuthIdentity,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
match auth {
|
match auth {
|
||||||
Identity::Anonymous => Err(StatusCode::FORBIDDEN.into()),
|
Identity::Anonymous => Err(StatusCode::FORBIDDEN),
|
||||||
Identity::Remote(_) => Err(StatusCode::FORBIDDEN.into()),
|
Identity::Remote(_) => Err(StatusCode::FORBIDDEN),
|
||||||
Identity::Local(user) => if ctx.uid(id.clone()) == user {
|
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 {
|
} else {
|
||||||
Err(StatusCode::FORBIDDEN.into())
|
Err(StatusCode::FORBIDDEN)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,33 +26,103 @@ pub async fn page(
|
||||||
AuthIdentity(auth): AuthIdentity,
|
AuthIdentity(auth): AuthIdentity,
|
||||||
Query(page): Query<Pagination>,
|
Query(page): Query<Pagination>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> crate::Result<JsonLD<serde_json::Value>> {
|
||||||
let Identity::Local(uid) = &auth else {
|
let uid = ctx.uid(id.clone());
|
||||||
// local inbox is only for local users
|
match auth {
|
||||||
return Err(UpubError::forbidden());
|
Identity::Anonymous => Err(StatusCode::FORBIDDEN.into()),
|
||||||
};
|
Identity::Remote(_) => Err(StatusCode::FORBIDDEN.into()),
|
||||||
if uid != &ctx.uid(id.clone()) {
|
Identity::Local(user) => if uid == user {
|
||||||
return Err(UpubError::forbidden());
|
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(
|
match crate::tools::Prefixer::new(select)
|
||||||
url!(ctx, "/users/{id}/inbox/page"),
|
.add_columns(model::activity::Entity)
|
||||||
Condition::any()
|
.add_columns(model::object::Entity)
|
||||||
.add(model::addressing::Column::Actor.eq(uid))
|
.selector
|
||||||
.add(model::object::Column::AttributedTo.eq(uid))
|
.join(JoinType::LeftJoin, model::activity::Relation::Addressing.def().rev())
|
||||||
.add(model::activity::Column::Actor.eq(uid)),
|
.join(JoinType::LeftJoin, model::object::Relation::Activity.def().rev())
|
||||||
ctx.db(),
|
.limit(limit)
|
||||||
page,
|
.offset(offset)
|
||||||
auth.my_id(),
|
.into_model::<crate::tools::ActivityWithObject>()
|
||||||
)
|
.all(ctx.db())
|
||||||
.await
|
.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(
|
pub async fn post(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(_id): Path<String>,
|
Path(_id): Path<String>,
|
||||||
AuthIdentity(_auth): AuthIdentity,
|
Json(activity): Json<serde_json::Value>
|
||||||
Json(activity): Json<serde_json::Value>,
|
|
||||||
) -> Result<(), UpubError> {
|
) -> Result<(), UpubError> {
|
||||||
// POSTing to user inboxes is effectively the same as POSTing to the main inbox
|
match activity.base_type() {
|
||||||
super::super::inbox::post(State(ctx), AuthIdentity(_auth), Json(activity)).await
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,99 +4,69 @@ pub mod outbox;
|
||||||
|
|
||||||
pub mod following;
|
pub mod following;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::{extract::{Path, State}, http::StatusCode};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect, SelectColumns};
|
use sea_orm::EntityTrait;
|
||||||
|
|
||||||
use apb::{ActorMut, EndpointsMut, Node};
|
use apb::{PublicKeyMut, ActorMut, DocumentMut, DocumentType, ObjectMut, BaseMut, Node};
|
||||||
use crate::{errors::UpubError, model::{self, user}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}, url};
|
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(
|
pub async fn view(State(ctx) : State<Context>, Path(id): Path<String>) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
State(ctx) : State<Context>,
|
match user::Entity::find_by_id(ctx.uid(id.clone()))
|
||||||
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)
|
|
||||||
.find_also_related(model::config::Entity)
|
.find_also_related(model::config::Entity)
|
||||||
.one(ctx.db()).await?
|
.one(ctx.db()).await
|
||||||
{
|
{
|
||||||
// local user
|
// local user
|
||||||
Some((user_model, Some(cfg))) => {
|
Ok(Some((user, Some(_cfg)))) => {
|
||||||
let mut user = user_model.ap()
|
Ok(JsonLD(ap_user(user.clone()) // ew ugly clone TODO
|
||||||
.set_inbox(Node::link(url!(ctx, "/users/{id}/inbox")))
|
.set_inbox(Node::link(url!(ctx, "/users/{id}/inbox")))
|
||||||
.set_outbox(Node::link(url!(ctx, "/users/{id}/outbox")))
|
.set_outbox(Node::link(url!(ctx, "/users/{id}/outbox")))
|
||||||
.set_following(Node::link(url!(ctx, "/users/{id}/following")))
|
.set_following(Node::link(url!(ctx, "/users/{id}/following")))
|
||||||
.set_followers(Node::link(url!(ctx, "/users/{id}/followers")))
|
.set_followers(Node::link(url!(ctx, "/users/{id}/followers")))
|
||||||
.set_following_me(following_me)
|
// .set_public_key(user.public_key) // TODO
|
||||||
.set_followed_by_me(followed_by_me)
|
.ld_context()
|
||||||
.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()))
|
|
||||||
},
|
},
|
||||||
// remote user
|
// remote user
|
||||||
Some((user_model, None)) => Ok(JsonLD(
|
Ok(Some((user, None))) => Ok(JsonLD(ap_user(user).ld_context())),
|
||||||
user_model.ap()
|
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||||
.set_following_me(following_me)
|
Err(e) => {
|
||||||
.set_followed_by_me(followed_by_me)
|
tracing::error!("error querying for user: {e}");
|
||||||
.ld_context()
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
)),
|
},
|
||||||
None => Err(UpubError::not_found()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,41 +1,66 @@
|
||||||
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
|
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 apb::{server::Outbox, AcceptType, ActivityMut, ActivityType, Base, BaseType, Node, ObjectType, RejectType};
|
||||||
use crate::{errors::UpubError, model, routes::activitypub::{CreationResult, JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, url};
|
use crate::{routes::activitypub::{jsonld::LD, CreationResult, JsonLD, Pagination}, server::auth::{AuthIdentity, Identity}, errors::UpubError, model, server::Context, url};
|
||||||
|
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
crate::server::builders::collection(&url!(ctx, "/users/{id}/outbox"), None)
|
Ok(JsonLD(
|
||||||
|
ctx.ap_collection(&url!(ctx, "/users/{id}/outbox"), None).ld_context()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn page(
|
pub async fn page(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(page): Query<Pagination>,
|
Query(page): Query<Pagination>,
|
||||||
AuthIdentity(auth): AuthIdentity,
|
AuthIdentity(_auth): AuthIdentity,
|
||||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||||
let uid = if id.starts_with('+') {
|
let limit = page.batch.unwrap_or(20).min(50);
|
||||||
format!("https://{}", id.replacen('+', "", 1).replace('@', "/"))
|
let offset = page.offset.unwrap_or(0);
|
||||||
} else {
|
|
||||||
ctx.uid(id.clone())
|
// let mut conditions = Condition::any()
|
||||||
};
|
// .add(model::addressing::Column::Actor.eq(PUBLIC_TARGET));
|
||||||
crate::server::builders::paginate(
|
|
||||||
url!(ctx, "/users/{id}/outbox/page"),
|
// if let Identity::User(ref x) = auth {
|
||||||
Condition::all()
|
// conditions = conditions.add(model::addressing::Column::Actor.eq(x));
|
||||||
.add(auth.filter_condition())
|
// }
|
||||||
.add(
|
|
||||||
Condition::any()
|
// if let Identity::Server(ref x) = auth {
|
||||||
.add(model::activity::Column::Actor.eq(&uid))
|
// conditions = conditions.add(model::addressing::Column::Server.eq(x));
|
||||||
.add(model::object::Column::AttributedTo.eq(&uid))
|
// }
|
||||||
),
|
|
||||||
ctx.db(),
|
match model::activity::Entity::find()
|
||||||
page,
|
.find_also_related(model::object::Entity)
|
||||||
auth.my_id(),
|
.order_by(model::activity::Column::Published, Order::Desc)
|
||||||
)
|
.limit(limit)
|
||||||
.await
|
.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(
|
pub async fn post(
|
||||||
|
@ -48,7 +73,6 @@ pub async fn post(
|
||||||
Identity::Anonymous => Err(StatusCode::UNAUTHORIZED.into()),
|
Identity::Anonymous => Err(StatusCode::UNAUTHORIZED.into()),
|
||||||
Identity::Remote(_) => Err(StatusCode::NOT_IMPLEMENTED.into()),
|
Identity::Remote(_) => Err(StatusCode::NOT_IMPLEMENTED.into()),
|
||||||
Identity::Local(uid) => if ctx.uid(id.clone()) == uid {
|
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() {
|
match activity.base_type() {
|
||||||
None => Err(StatusCode::BAD_REQUEST.into()),
|
None => Err(StatusCode::BAD_REQUEST.into()),
|
||||||
|
|
||||||
|
@ -66,8 +90,8 @@ pub async fn post(
|
||||||
Some(BaseType::Object(ObjectType::Activity(ActivityType::Follow))) =>
|
Some(BaseType::Object(ObjectType::Activity(ActivityType::Follow))) =>
|
||||||
Ok(CreationResult(ctx.follow(uid, activity).await?)),
|
Ok(CreationResult(ctx.follow(uid, activity).await?)),
|
||||||
|
|
||||||
Some(BaseType::Object(ObjectType::Activity(ActivityType::Announce))) =>
|
Some(BaseType::Object(ObjectType::Activity(ActivityType::Undo))) =>
|
||||||
Ok(CreationResult(ctx.announce(uid, activity).await?)),
|
Ok(CreationResult(ctx.undo(uid, activity).await?)),
|
||||||
|
|
||||||
Some(BaseType::Object(ObjectType::Activity(ActivityType::Accept(AcceptType::Accept)))) =>
|
Some(BaseType::Object(ObjectType::Activity(ActivityType::Accept(AcceptType::Accept)))) =>
|
||||||
Ok(CreationResult(ctx.accept(uid, activity).await?)),
|
Ok(CreationResult(ctx.accept(uid, activity).await?)),
|
||||||
|
@ -75,15 +99,6 @@ pub async fn post(
|
||||||
Some(BaseType::Object(ObjectType::Activity(ActivityType::Reject(RejectType::Reject)))) =>
|
Some(BaseType::Object(ObjectType::Activity(ActivityType::Reject(RejectType::Reject)))) =>
|
||||||
Ok(CreationResult(ctx.reject(uid, activity).await?)),
|
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()),
|
Some(_) => Err(StatusCode::NOT_IMPLEMENTED.into()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,7 @@ use axum::{extract::{Path, Query, State}, http::StatusCode, response::{IntoRespo
|
||||||
use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink};
|
use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink};
|
||||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||||
|
|
||||||
use crate::{model, server::Context, url, VERSION};
|
use crate::{model, server::Context, VERSION};
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct NodeInfoDiscovery {
|
pub struct NodeInfoDiscovery {
|
||||||
|
@ -20,11 +20,11 @@ pub async fn nodeinfo_discovery(State(ctx): State<Context>) -> Json<NodeInfoDisc
|
||||||
links: vec![
|
links: vec![
|
||||||
NodeInfoDiscoveryRel {
|
NodeInfoDiscoveryRel {
|
||||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".into(),
|
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 {
|
NodeInfoDiscoveryRel {
|
||||||
rel: "http://nodeinfo.diaspora.software/ns/schema/2.1".into(),
|
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:", "")
|
.replace("acct:", "")
|
||||||
.split_once('@')
|
.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());
|
let uid = ctx.uid(user.to_string());
|
||||||
match model::user::Entity::find_by_id(uid)
|
match model::user::Entity::find_by_id(uid)
|
||||||
.one(ctx.db())
|
.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">
|
<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" />
|
<Link type="application/xrd+xml" template="{}{}/.well-known/webfinger?resource={{uri}}" rel="lrdd" />
|
||||||
</XRD>"#,
|
</XRD>"#,
|
||||||
ctx.protocol(), ctx.domain())
|
ctx.protocol(), ctx.base())
|
||||||
).into_response()
|
).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,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
|
@ -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!()
|
|
||||||
// }
|
|
||||||
|
|
|
@ -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,
|
|
||||||
}))
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod instance;
|
|
||||||
|
|
||||||
use axum::{http::StatusCode, routing::{delete, get, patch, post}, Router};
|
use axum::{http::StatusCode, routing::{delete, get, patch, post}, Router};
|
||||||
use crate::server::Context;
|
use crate::server::Context;
|
||||||
|
@ -71,8 +70,6 @@ impl MastodonRouter for Router<Context> {
|
||||||
.route("/profile/avatar", delete(todo))
|
.route("/profile/avatar", delete(todo))
|
||||||
.route("/profile/header", delete(todo))
|
.route("/profile/header", delete(todo))
|
||||||
.route("/statuses", post(todo))
|
.route("/statuses", post(todo))
|
||||||
// ...
|
|
||||||
.route("/instance", get(mas::instance::get))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ pub mod mastodon;
|
||||||
#[cfg(not(feature = "mastodon"))]
|
#[cfg(not(feature = "mastodon"))]
|
||||||
pub mod mastodon {
|
pub mod mastodon {
|
||||||
pub trait MastodonRouter {
|
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> {}
|
impl MastodonRouter for axum::Router<crate::server::Context> {}
|
||||||
|
|
|
@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,10 @@
|
||||||
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}};
|
use std::collections::BTreeMap;
|
||||||
use reqwest::StatusCode;
|
|
||||||
|
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts, StatusCode}};
|
||||||
|
use openssl::hash::MessageDigest;
|
||||||
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
|
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
|
||||||
|
|
||||||
use crate::{errors::UpubError, model, server::Context};
|
use crate::{model, server::Context};
|
||||||
|
|
||||||
use super::{fetcher::Fetcher, httpsign::HttpSignature};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Identity {
|
pub enum Identity {
|
||||||
|
@ -13,62 +13,6 @@ pub enum Identity {
|
||||||
Remote(String),
|
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);
|
pub struct AuthIdentity(pub Identity);
|
||||||
|
|
||||||
#[axum::async_trait]
|
#[axum::async_trait]
|
||||||
|
@ -77,7 +21,7 @@ where
|
||||||
Context: FromRef<S>,
|
Context: FromRef<S>,
|
||||||
S: Send + Sync,
|
S: Send + Sync,
|
||||||
{
|
{
|
||||||
type Rejection = UpubError;
|
type Rejection = StatusCode;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let ctx = Context::from_ref(state);
|
let ctx = Context::from_ref(state);
|
||||||
|
@ -91,55 +35,102 @@ where
|
||||||
|
|
||||||
if auth_header.starts_with("Bearer ") {
|
if auth_header.starts_with("Bearer ") {
|
||||||
match model::session::Entity::find_by_id(auth_header.replace("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())
|
.one(ctx.db())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(x)) => identity = Identity::Local(x.actor),
|
Ok(Some(x)) => identity = Identity::Local(x.actor),
|
||||||
Ok(None) => return Err(UpubError::unauthorized()),
|
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("failed querying user session: {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
|
// if let Some(sig) = parts
|
||||||
.headers
|
// .headers
|
||||||
.get("Signature")
|
// .get("Signature")
|
||||||
.map(|v| v.to_str().unwrap_or(""))
|
// .map(|v| v.to_str().unwrap_or(""))
|
||||||
{
|
// {
|
||||||
let mut http_signature = HttpSignature::parse(sig);
|
// 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 = ctx.fetch().user(&user_id).await.map_err(|_e| StatusCode::UNAUTHORIZED)?;
|
||||||
let user_id = http_signature.key_id
|
// let pubkey = PKey::public_key_from_pem(user.public_key.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
.split('#')
|
// let mut verifier = Verifier::new(signature.digest(), &pubkey).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
.next().ok_or(UpubError::bad_request())?
|
// verifier.update(data.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
.to_string();
|
// if verifier.verify(signature.signature.as_bytes()).map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)? {
|
||||||
|
// identity = Identity::Remote(user_id);
|
||||||
match ctx.fetch_user(&user_id).await {
|
// } else {
|
||||||
Ok(user) => match http_signature
|
// return Err(StatusCode::FORBIDDEN);
|
||||||
.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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(AuthIdentity(identity))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
))
|
|
||||||
}
|
|
|
@ -1,37 +1,37 @@
|
||||||
use std::{collections::BTreeSet, sync::Arc};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use apb::{BaseMut, CollectionMut, CollectionPageMut};
|
||||||
use openssl::rsa::Rsa;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Context(Arc<ContextInner>);
|
pub struct Context(Arc<ContextInner>);
|
||||||
struct ContextInner {
|
struct ContextInner {
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
config: Config,
|
|
||||||
domain: String,
|
domain: String,
|
||||||
protocol: String,
|
protocol: String,
|
||||||
|
fetcher: Fetcher,
|
||||||
dispatcher: Dispatcher,
|
dispatcher: Dispatcher,
|
||||||
// TODO keep these pre-parsed
|
// TODO keep these pre-parsed
|
||||||
app: model::application::Model,
|
app: model::application::Model,
|
||||||
relays: BTreeSet<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! url {
|
macro_rules! url {
|
||||||
($ctx:expr, $($args: tt)*) => {
|
($ctx:expr, $($args: tt)*) => {
|
||||||
format!("{}{}{}", $ctx.protocol(), $ctx.domain(), format!($($args)*))
|
format!("{}{}{}", $ctx.protocol(), $ctx.base(), format!($($args)*))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
|
|
||||||
// TODO slim constructor down, maybe make a builder?
|
// 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://")
|
let protocol = if domain.starts_with("http://")
|
||||||
{ "http://" } else { "https://" }.to_string();
|
{ "http://" } else { "https://" }.to_string();
|
||||||
if domain.ends_with('/') {
|
if domain.ends_with('/') {
|
||||||
|
@ -40,7 +40,7 @@ impl Context {
|
||||||
if domain.starts_with("http") {
|
if domain.starts_with("http") {
|
||||||
domain = domain.replace("https://", "").replace("http://", "");
|
domain = domain.replace("https://", "").replace("http://", "");
|
||||||
}
|
}
|
||||||
let dispatcher = Dispatcher::default();
|
let dispatcher = Dispatcher::new();
|
||||||
for _ in 0..1 { // TODO customize delivery workers amount
|
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!!
|
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()
|
let fetcher = Fetcher::new(db.clone(), domain.clone(), app.private_key.clone());
|
||||||
.select_only()
|
|
||||||
.select_column(model::relay::Column::Id)
|
|
||||||
.filter(model::relay::Column::Accepted.eq(true))
|
|
||||||
.into_tuple::<String>()
|
|
||||||
.all(&db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Context(Arc::new(ContextInner {
|
Ok(Context(Arc::new(ContextInner {
|
||||||
db, domain, protocol, app, dispatcher, config,
|
db, domain, protocol, app, fetcher, dispatcher,
|
||||||
relays: BTreeSet::from_iter(relays.into_iter()),
|
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,11 +78,7 @@ impl Context {
|
||||||
&self.0.db
|
&self.0.db
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cfg(&self) -> &Config {
|
pub fn base(&self) -> &str {
|
||||||
&self.0.config
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn domain(&self) -> &str {
|
|
||||||
&self.0.domain
|
&self.0.domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,26 +86,16 @@ impl Context {
|
||||||
&self.0.protocol
|
&self.0.protocol
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn base(&self) -> String {
|
|
||||||
format!("{}{}", self.0.protocol, self.0.domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uri(&self, entity: &str, id: String) -> String {
|
pub fn uri(&self, entity: &str, id: String) -> String {
|
||||||
if id.starts_with("http") { // ready-to-use id
|
if id.starts_with("http") { id } else {
|
||||||
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
|
|
||||||
format!("{}{}/{}/{}", self.0.protocol, self.0.domain, entity, id)
|
format!("{}{}/{}/{}", self.0.protocol, self.0.domain, entity, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fetch(&self) -> &Fetcher {
|
||||||
|
&self.0.fetcher
|
||||||
|
}
|
||||||
|
|
||||||
/// get full user id uri
|
/// get full user id uri
|
||||||
pub fn uid(&self, id: String) -> String {
|
pub fn uid(&self, id: String) -> String {
|
||||||
self.uri("users", id)
|
self.uri("users", id)
|
||||||
|
@ -133,14 +112,11 @@ impl Context {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// get bare id, usually an uuid but unspecified
|
/// get bare id, usually an uuid but unspecified
|
||||||
pub fn id(&self, uri: &str) -> String {
|
pub fn id(&self, id: String) -> String {
|
||||||
if uri.starts_with(&self.0.domain) {
|
if id.starts_with(&self.0.domain) {
|
||||||
uri.split('/').last().unwrap_or("").to_string()
|
id.split('/').last().unwrap_or("").to_string()
|
||||||
} else {
|
} else {
|
||||||
uri
|
id
|
||||||
.replace("https://", "+")
|
|
||||||
.replace("http://", "+")
|
|
||||||
.replace('/', "@")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,45 +130,33 @@ impl Context {
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_local(&self, id: &str) -> bool {
|
pub async fn expand_addressing(&self, uid: &str, mut targets: Vec<String>) -> crate::Result<Vec<String>> {
|
||||||
// TODO consider precalculating once this format!
|
let following_addr = format!("{uid}/followers");
|
||||||
id.starts_with(&format!("{}{}", self.0.protocol, self.0.domain))
|
if let Some(i) = targets.iter().position(|x| x == &following_addr) {
|
||||||
}
|
targets.remove(i);
|
||||||
|
model::relation::Entity::find()
|
||||||
pub async fn expand_addressing(&self, targets: Vec<String>) -> crate::Result<Vec<String>> {
|
.filter(Condition::all().add(model::relation::Column::Following.eq(uid.to_string())))
|
||||||
let mut out = Vec::new();
|
.select_only()
|
||||||
for target in targets {
|
.select_column(model::relation::Column::Follower)
|
||||||
if target.ends_with("/followers") {
|
.into_tuple::<String>()
|
||||||
let target_id = target.replace("/followers", "");
|
.all(self.db())
|
||||||
model::relation::Entity::find()
|
.await?
|
||||||
.filter(model::relation::Column::Following.eq(target_id))
|
.into_iter()
|
||||||
.select_only()
|
.for_each(|x| targets.push(x));
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn address_to(&self, aid: Option<&str>, oid: Option<&str>, targets: &[String]) -> crate::Result<()> {
|
pub async fn address_to(&self, aid: &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);
|
|
||||||
let addressings : Vec<model::addressing::ActiveModel> = targets
|
let addressings : Vec<model::addressing::ActiveModel> = targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|to| !to.is_empty())
|
.filter(|to| !to.is_empty())
|
||||||
.filter(|to| !to.ends_with("/followers"))
|
.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 {
|
.map(|to| model::addressing::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
server: Set(Context::server(to)),
|
server: Set(Context::server(to)),
|
||||||
actor: Set(to.to_string()),
|
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())),
|
object: Set(oid.map(|x| x.to_string())),
|
||||||
published: Set(chrono::Utc::now()),
|
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<()> {
|
pub async fn deliver_to(&self, aid: &str, from: &str, targets: &[String]) -> crate::Result<()> {
|
||||||
let mut deliveries = Vec::new();
|
let deliveries : Vec<model::delivery::ActiveModel> = targets
|
||||||
for target in targets.iter()
|
.iter()
|
||||||
.filter(|to| !to.is_empty())
|
.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)
|
.filter(|to| to != &apb::target::PUBLIC)
|
||||||
{
|
.map(|to| model::delivery::ActiveModel {
|
||||||
// TODO fetch concurrently
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
match self.fetch_user(target).await {
|
actor: Set(from.to_string()),
|
||||||
Ok(model::user::Model { inbox: Some(inbox), .. }) => deliveries.push(
|
// TODO we should resolve each user by id and check its inbox because we can't assume
|
||||||
model::delivery::ActiveModel {
|
// it's /users/{id}/inbox for every software, but oh well it's waaaaay easier now
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
target: Set(format!("{}/inbox", to)),
|
||||||
actor: Set(from.to_string()),
|
activity: Set(aid.to_string()),
|
||||||
// TODO we should resolve each user by id and check its inbox because we can't assume
|
created: Set(chrono::Utc::now()),
|
||||||
// it's /users/{id}/inbox for every software, but oh well it's waaaaay easier now
|
not_before: Set(chrono::Utc::now()),
|
||||||
target: Set(inbox),
|
attempt: Set(0),
|
||||||
activity: Set(aid.to_string()),
|
})
|
||||||
created: Set(chrono::Utc::now()),
|
.collect();
|
||||||
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}'"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !deliveries.is_empty() {
|
if !deliveries.is_empty() {
|
||||||
model::delivery::Entity::insert_many(deliveries)
|
model::delivery::Entity::insert_many(deliveries)
|
||||||
|
@ -245,14 +201,29 @@ impl Context {
|
||||||
Ok(())
|
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<()> {
|
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?;
|
let addressed = self.expand_addressing(uid, activity_targets).await?;
|
||||||
self.address_to(Some(aid), oid, &addressed).await?;
|
self.address_to(aid, oid, &addressed).await?;
|
||||||
self.deliver_to(aid, uid, &addressed).await?;
|
self.deliver_to(aid, uid, &addressed).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_relay(&self, id: &str) -> bool {
|
|
||||||
self.0.relays.contains(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
use reqwest::Method;
|
use base64::Engine;
|
||||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
|
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 tokio::{sync::broadcast, task::JoinHandle};
|
||||||
|
|
||||||
use apb::{ActivityMut, Node};
|
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 {
|
pub struct Dispatcher {
|
||||||
waker: broadcast::Sender<()>,
|
waker: broadcast::Sender<()>,
|
||||||
|
@ -17,14 +19,13 @@ impl Default for Dispatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dispatcher {
|
impl Dispatcher {
|
||||||
|
pub fn new() -> Self { Dispatcher::default() }
|
||||||
|
|
||||||
pub fn spawn(&self, db: DatabaseConnection, domain: String, poll_interval: u64) -> JoinHandle<()> {
|
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 {
|
tokio::spawn(async move {
|
||||||
loop {
|
if let Err(e) = worker(db, domain, poll_interval, waker).await {
|
||||||
if let Err(e) = worker(&db, &domain, poll_interval, &mut waker).await {
|
tracing::error!("delivery worker exited with error: {e}");
|
||||||
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 {
|
loop {
|
||||||
let Some(delivery) = model::delivery::Entity::find()
|
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)
|
.order_by(model::delivery::Column::NotBefore, Order::Asc)
|
||||||
.one(db)
|
.one(&db)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
@ -58,7 +59,7 @@ async fn worker(db: &DatabaseConnection, domain: &str, poll_interval: u64, waker
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let del = model::delivery::Entity::delete(del_row)
|
let del = model::delivery::Entity::delete(del_row)
|
||||||
.exec(db)
|
.exec(&db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if del.rows_affected == 0 {
|
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)
|
let payload = match model::activity::Entity::find_by_id(&delivery.activity)
|
||||||
.find_also_related(model::object::Entity)
|
.find_also_related(model::object::Entity)
|
||||||
.one(db)
|
.one(&db)
|
||||||
.await? // TODO probably should not fail here and at least re-insert the delivery
|
.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))) => ap_activity(activity).set_object(Node::object(ap_object(object))),
|
||||||
Some((activity, Some(object))) => {
|
Some((activity, None)) => ap_activity(activity),
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("skipping dispatch for deleted object {}", delivery.activity);
|
tracing::warn!("skipping dispatch for deleted object {}", delivery.activity);
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let key = if delivery.actor == format!("https://{domain}") {
|
let Some(model::user::Model{ private_key: Some(key), .. }) = model::user::Entity::find_by_id(&delivery.actor)
|
||||||
let Some(model::application::Model { private_key: key, .. }) = model::application::Entity::find()
|
.one(&db).await?
|
||||||
.one(db).await?
|
else {
|
||||||
else {
|
tracing::error!("can not dispatch activity for user without private key: {}", delivery.actor);
|
||||||
tracing::error!("no private key configured for application");
|
continue;
|
||||||
continue;
|
|
||||||
};
|
|
||||||
key
|
|
||||||
} else {
|
|
||||||
let Some(model::user::Model{ private_key: Some(key), .. }) = model::user::Entity::find_by_id(&delivery.actor)
|
|
||||||
.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(
|
if let Err(e) = deliver(&key, &delivery.target, &delivery.actor, payload, &domain).await {
|
||||||
Method::POST, &delivery.target,
|
|
||||||
Some(&serde_json::to_string(&payload).unwrap()),
|
|
||||||
&delivery.actor, &key, domain
|
|
||||||
).await {
|
|
||||||
tracing::warn!("failed delivery of {} to {} : {e}", delivery.activity, delivery.target);
|
tracing::warn!("failed delivery of {} to {} : {e}", delivery.activity, delivery.target);
|
||||||
let new_delivery = model::delivery::ActiveModel {
|
let new_delivery = model::delivery::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
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),
|
created: sea_orm::ActiveValue::Set(delivery.created),
|
||||||
attempt: sea_orm::ActiveValue::Set(delivery.attempt + 1),
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 crate::{VERSION, model};
|
||||||
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::{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]
|
#[error("error operating on database: {0}")]
|
||||||
pub trait Fetcher {
|
Database(#[from] sea_orm::DbErr),
|
||||||
async fn webfinger(&self, user: &str, host: &str) -> crate::Result<String>;
|
|
||||||
|
|
||||||
async fn fetch_user(&self, id: &str) -> crate::Result<model::user::Model>;
|
#[error("missing field when constructing object: {0}")]
|
||||||
async fn pull_user(&self, id: &str) -> crate::Result<model::user::Model>;
|
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>;
|
|
||||||
|
|
||||||
async fn fetch_activity(&self, id: &str) -> crate::Result<model::activity::Model>;
|
|
||||||
async fn pull_activity(&self, id: &str) -> crate::Result<model::activity::Model>;
|
|
||||||
|
|
||||||
async fn fetch_thread(&self, id: &str) -> crate::Result<()>;
|
|
||||||
|
|
||||||
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())
|
|
||||||
.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?)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
#[axum::async_trait]
|
impl Fetcher {
|
||||||
impl Fetcher for Context {
|
pub fn new(db: DatabaseConnection, domain: String, key: String) -> Self {
|
||||||
async fn webfinger(&self, user: &str, host: &str) -> crate::Result<String> {
|
Fetcher { db, domain, _key: PKey::private_key_from_pem(key.as_bytes()).unwrap() }
|
||||||
let subject = format!("acct:{user}@{host}");
|
}
|
||||||
let webfinger_uri = format!("https://{host}/.well-known/webfinger?resource={subject}");
|
|
||||||
let resource = reqwest::Client::new()
|
pub async fn user(&self, id: &str) -> Result<model::user::Model, FetchError> {
|
||||||
.get(webfinger_uri)
|
if let Some(x) = model::user::Entity::find_by_id(id).one(&self.db).await? {
|
||||||
.header(ACCEPT, "application/jrd+json")
|
return Ok(x); // already in db, easy
|
||||||
.header(USER_AGENT, format!("upub+{VERSION} ({})", self.domain()))
|
}
|
||||||
|
|
||||||
|
// 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()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.json::<jrd::JsonResourceDescriptor>()
|
.json::<serde_json::Value>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if resource.subject != subject {
|
let user_model = model::user::Model::new(&user)?;
|
||||||
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())
|
model::user::Entity::insert(user_model.clone().into_active_model())
|
||||||
.exec(self.db()).await?;
|
.exec(&self.db).await?;
|
||||||
|
|
||||||
Ok(user_model)
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,9 @@
|
||||||
use apb::{target::Addressed, Activity, Base, Object};
|
use apb::{target::Addressed, Activity, Base, Object};
|
||||||
use reqwest::StatusCode;
|
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
|
||||||
use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect, SelectColumns, 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]
|
#[axum::async_trait]
|
||||||
|
@ -12,170 +11,67 @@ impl apb::server::Inbox for Context {
|
||||||
type Error = UpubError;
|
type Error = UpubError;
|
||||||
type Activity = serde_json::Value;
|
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 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 {
|
let Some(object_node) = activity.object().extract() else {
|
||||||
// TODO we could process non-embedded activities or arrays but im lazy rn
|
// 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());
|
tracing::error!("refusing to process activity without embedded object: {}", serde_json::to_string_pretty(&activity).unwrap());
|
||||||
return Err(UpubError::unprocessable());
|
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 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::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::activity::Entity::insert(activity_model.into_active_model()).exec(self.db()).await?;
|
||||||
for attachment in object_node.attachment() {
|
self.address_to(&aid, Some(&oid), &activity_targets).await?;
|
||||||
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?;
|
|
||||||
tracing::info!("{} posted {}", aid, oid);
|
tracing::info!("{} posted {}", aid, oid);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn like(&self, _: String, activity: serde_json::Value) -> crate::Result<()> {
|
async fn like(&self, activity: serde_json::Value) -> crate::Result<()> {
|
||||||
let aid = activity.id().ok_or(UpubError::bad_request())?;
|
let aid = activity.actor().id().ok_or(UpubError::bad_request())?;
|
||||||
let uid = activity.actor().id().ok_or(UpubError::bad_request())?;
|
|
||||||
let oid = activity.object().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 {
|
let like = model::like::ActiveModel {
|
||||||
id: sea_orm::ActiveValue::NotSet,
|
id: sea_orm::ActiveValue::NotSet,
|
||||||
actor: sea_orm::Set(uid.clone()),
|
actor: sea_orm::Set(aid.clone()),
|
||||||
likes: sea_orm::Set(oid.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 {
|
match model::like::Entity::insert(like).exec(self.db()).await {
|
||||||
Err(sea_orm::DbErr::RecordNotInserted) => Err(UpubError::not_modified()),
|
Err(sea_orm::DbErr::RecordNotInserted) => Err(UpubError::not_modified()),
|
||||||
Err(sea_orm::DbErr::Exec(_)) => Err(UpubError::not_modified()), // bad fix for sqlite
|
Err(sea_orm::DbErr::Exec(_)) => Err(UpubError::not_modified()), // bad fix for sqlite
|
||||||
Err(e) => {
|
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())
|
Err(UpubError::internal_server_error())
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
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()
|
model::object::Entity::update_many()
|
||||||
.col_expr(model::object::Column::Likes, Expr::col(model::object::Column::Likes).add(1))
|
.col_expr(model::object::Column::Likes, Expr::col(model::object::Column::Likes).add(1))
|
||||||
.filter(model::object::Column::Id.eq(oid.clone()))
|
.filter(model::object::Column::Id.eq(oid.clone()))
|
||||||
.exec(self.db())
|
.exec(self.db())
|
||||||
.await?;
|
.await?;
|
||||||
tracing::info!("{} liked {}", uid, oid);
|
tracing::info!("{} liked {}", aid, oid);
|
||||||
Ok(())
|
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 activity_model = model::activity::Model::new(&activity)?;
|
||||||
let aid = activity_model.id.clone();
|
let aid = activity_model.id.clone();
|
||||||
let target_user_id = activity_model.object
|
tracing::info!("{} wants to follow {}", activity_model.actor, activity_model.object.as_deref().unwrap_or("<no-one???>"));
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(UpubError::bad_request)?
|
|
||||||
.to_string();
|
|
||||||
tracing::info!("{} wants to follow {}", activity_model.actor, target_user_id);
|
|
||||||
model::activity::Entity::insert(activity_model.into_active_model())
|
model::activity::Entity::insert(activity_model.into_active_model())
|
||||||
.exec(self.db()).await?;
|
.exec(self.db()).await?;
|
||||||
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
|
self.address_to(&aid, None, &activity_targets).await?;
|
||||||
if !expanded_addressing.contains(&target_user_id) {
|
|
||||||
expanded_addressing.push(target_user_id);
|
|
||||||
}
|
|
||||||
self.address_to(Some(&aid), None, &expanded_addressing).await?;
|
|
||||||
Ok(())
|
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
|
// TODO what about TentativeAccept
|
||||||
let activity_model = model::activity::Model::new(&activity)?;
|
let activity_model = model::activity::Model::new(&activity)?;
|
||||||
|
let Some(follow_request_id) = activity_model.object else {
|
||||||
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 {
|
|
||||||
return Err(UpubError::bad_request());
|
return Err(UpubError::bad_request());
|
||||||
};
|
};
|
||||||
let Some(follow_activity) = model::activity::Entity::find_by_id(follow_request_id)
|
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);
|
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::Entity::insert(
|
||||||
model::relation::ActiveModel {
|
model::relation::ActiveModel {
|
||||||
follower: Set(follow_activity.actor.clone()),
|
follower: Set(follow_activity.actor),
|
||||||
following: Set(activity_model.actor),
|
following: Set(activity_model.actor),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
).exec(self.db()).await?;
|
).exec(self.db()).await?;
|
||||||
|
|
||||||
let mut expanded_addressing = self.expand_addressing(activity.addressed()).await?;
|
self.address_to(&activity_model.id, None, &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?;
|
|
||||||
Ok(())
|
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?
|
// TODO what about TentativeReject?
|
||||||
let activity_model = model::activity::Model::new(&activity)?;
|
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());
|
return Err(UpubError::bad_request());
|
||||||
};
|
};
|
||||||
let Some(follow_activity) = model::activity::Entity::find_by_id(follow_request_id)
|
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 {
|
if follow_activity.object.unwrap_or("".into()) != activity_model.actor {
|
||||||
return Err(UpubError::forbidden());
|
return Err(UpubError::forbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("{} rejected follow request by {}", activity_model.actor, follow_activity.actor);
|
tracing::info!("{} rejected follow request by {}", activity_model.actor, follow_activity.actor);
|
||||||
|
self.address_to(&activity_model.id, None, &activity.addressed()).await?;
|
||||||
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?;
|
|
||||||
Ok(())
|
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
|
// TODO verify the signature before just deleting lmao
|
||||||
let oid = activity.object().id().ok_or(UpubError::bad_request())?;
|
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?
|
// 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::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");
|
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(())
|
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 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 {
|
let Some(object_node) = activity.object().extract() else {
|
||||||
// TODO we could process non-embedded activities or arrays but im lazy rn
|
// 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());
|
tracing::error!("refusing to process activity without embedded object: {}", serde_json::to_string_pretty(&activity).unwrap());
|
||||||
return Err(UpubError::unprocessable());
|
return Err(UpubError::unprocessable());
|
||||||
};
|
};
|
||||||
|
let aid = activity_model.id.clone();
|
||||||
let Some(oid) = object_node.id().map(|x| x.to_string()) else {
|
let Some(oid) = object_node.id().map(|x| x.to_string()) else {
|
||||||
return Err(UpubError::bad_request());
|
return Err(UpubError::bad_request());
|
||||||
};
|
};
|
||||||
// make sure we're allowed to edit this object
|
model::activity::Entity::insert(activity_model.into_active_model()).exec(self.db()).await?;
|
||||||
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());
|
|
||||||
};
|
|
||||||
match object_node.object_type() {
|
match object_node.object_type() {
|
||||||
Some(apb::ObjectType::Actor(_)) => {
|
Some(apb::ObjectType::Actor(_)) => {
|
||||||
// TODO oof here is an example of the weakness of this model, we have to go all the way
|
// 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
|
// back up to serde_json::Value because impl Object != impl Actor
|
||||||
let actor_model = model::user::Model::new(&object_node)?;
|
let actor_model = model::user::Model::new(&object_node)?;
|
||||||
let mut update_model = actor_model.into_active_model();
|
model::user::Entity::update(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?;
|
.exec(self.db()).await?;
|
||||||
},
|
},
|
||||||
Some(apb::ObjectType::Note) => {
|
Some(apb::ObjectType::Note) => {
|
||||||
let object_model = model::object::Model::new(&object_node)?;
|
let object_model = model::object::Model::new(&object_node)?;
|
||||||
let mut update_model = object_model.into_active_model();
|
model::object::Entity::update(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?;
|
.exec(self.db()).await?;
|
||||||
},
|
},
|
||||||
Some(t) => tracing::warn!("no side effects implemented for update type {t:?}"),
|
Some(t) => tracing::warn!("no side effects implemented for update type {t:?}"),
|
||||||
None => tracing::warn!("empty type on embedded updated object"),
|
None => tracing::warn!("empty type on embedded updated object"),
|
||||||
}
|
}
|
||||||
|
self.address_to(&aid, Some(&oid), &activity_targets).await?;
|
||||||
tracing::info!("{} updated {}", aid, oid);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn undo(&self, server: String, activity: serde_json::Value) -> crate::Result<()> {
|
async fn undo(&self, _activity: serde_json::Value) -> crate::Result<()> {
|
||||||
let uid = activity.actor().id().ok_or_else(UpubError::bad_request)?;
|
todo!()
|
||||||
// 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(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
pub mod admin;
|
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod dispatcher;
|
pub mod dispatcher;
|
||||||
pub mod fetcher;
|
pub mod fetcher;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod builders;
|
|
||||||
pub mod httpsign;
|
|
||||||
|
|
||||||
pub use context::Context;
|
pub use context::Context;
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
use apb::{target::Addressed, Activity, ActivityMut, ActorMut, BaseMut, Node, Object, ObjectMut, PublicKeyMut};
|
use apb::{target::Addressed, Activity, ActivityMut, BaseMut, Node, ObjectMut};
|
||||||
use reqwest::StatusCode;
|
use sea_orm::{EntityTrait, IntoActiveModel, Set};
|
||||||
use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, 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]
|
#[axum::async_trait]
|
||||||
|
@ -14,32 +13,15 @@ impl apb::server::Outbox for Context {
|
||||||
type Activity = serde_json::Value;
|
type Activity = serde_json::Value;
|
||||||
|
|
||||||
async fn create_note(&self, uid: String, object: serde_json::Value) -> crate::Result<String> {
|
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(uuid::Uuid::new_v4().to_string());
|
||||||
let oid = self.oid(raw_oid.clone());
|
|
||||||
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
||||||
let activity_targets = object.addressed();
|
let activity_targets = object.addressed();
|
||||||
let mut object_model = model::object::Model::new(
|
let object_model = model::object::Model::new(
|
||||||
&object
|
&object
|
||||||
.set_id(Some(&oid))
|
.set_id(Some(&oid))
|
||||||
.set_attributed_to(Node::link(uid.clone()))
|
.set_attributed_to(Node::link(uid.clone()))
|
||||||
.set_published(Some(chrono::Utc::now()))
|
.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 {
|
let activity_model = model::activity::Model {
|
||||||
id: aid.clone(),
|
id: aid.clone(),
|
||||||
activity_type: apb::ActivityType::Create,
|
activity_type: apb::ActivityType::Create,
|
||||||
|
@ -57,18 +39,6 @@ impl apb::server::Outbox for Context {
|
||||||
.exec(self.db()).await?;
|
.exec(self.db()).await?;
|
||||||
model::activity::Entity::insert(activity_model.into_active_model())
|
model::activity::Entity::insert(activity_model.into_active_model())
|
||||||
.exec(self.db()).await?;
|
.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?;
|
self.dispatch(&uid, activity_targets, &aid, Some(&oid)).await?;
|
||||||
|
|
||||||
|
@ -80,8 +50,7 @@ impl apb::server::Outbox for Context {
|
||||||
return Err(UpubError::bad_request());
|
return Err(UpubError::bad_request());
|
||||||
};
|
};
|
||||||
|
|
||||||
let raw_oid = uuid::Uuid::new_v4().to_string();
|
let oid = self.oid(uuid::Uuid::new_v4().to_string());
|
||||||
let oid = self.oid(raw_oid.clone());
|
|
||||||
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
||||||
let activity_targets = activity.addressed();
|
let activity_targets = activity.addressed();
|
||||||
let mut object_model = model::object::Model::new(
|
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_actor(Node::link(uid.clone()))
|
||||||
.set_published(Some(chrono::Utc::now()))
|
.set_published(Some(chrono::Utc::now()))
|
||||||
)?;
|
)?;
|
||||||
activity_model.object = Some(oid.clone());
|
|
||||||
object_model.to = activity_model.to.clone();
|
object_model.to = activity_model.to.clone();
|
||||||
object_model.bto = activity_model.bto.clone();
|
object_model.bto = activity_model.bto.clone();
|
||||||
object_model.cc = activity_model.cc.clone();
|
object_model.cc = activity_model.cc.clone();
|
||||||
object_model.bcc = activity_model.bcc.clone();
|
object_model.bcc = activity_model.bcc.clone();
|
||||||
if let Some(content) = object_model.content {
|
activity_model.object = Some(oid.clone());
|
||||||
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}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
model::object::Entity::insert(object_model.into_active_model())
|
model::object::Entity::insert(object_model.into_active_model())
|
||||||
.exec(self.db()).await?;
|
.exec(self.db()).await?;
|
||||||
model::activity::Entity::insert(activity_model.into_active_model())
|
model::activity::Entity::insert(activity_model.into_active_model())
|
||||||
.exec(self.db()).await?;
|
.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?;
|
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> {
|
async fn like(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
|
||||||
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
||||||
let activity_targets = activity.addressed();
|
let activity_targets = activity.addressed();
|
||||||
let oid = activity.object().id().ok_or_else(UpubError::bad_request)?;
|
let Some(oid) = activity.object().id() else {
|
||||||
self.fetch_object(&oid).await?;
|
return Err(UpubError::bad_request());
|
||||||
|
};
|
||||||
let activity_model = model::activity::Model::new(
|
let activity_model = model::activity::Model::new(
|
||||||
&activity
|
&activity
|
||||||
.set_id(Some(&aid))
|
.set_id(Some(&aid))
|
||||||
|
@ -153,18 +97,13 @@ impl apb::server::Outbox for Context {
|
||||||
|
|
||||||
let like_model = model::like::ActiveModel {
|
let like_model = model::like::ActiveModel {
|
||||||
actor: Set(uid.clone()),
|
actor: Set(uid.clone()),
|
||||||
likes: Set(oid.clone()),
|
likes: Set(oid),
|
||||||
date: Set(chrono::Utc::now()),
|
date: Set(chrono::Utc::now()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
model::like::Entity::insert(like_model).exec(self.db()).await?;
|
model::like::Entity::insert(like_model).exec(self.db()).await?;
|
||||||
model::activity::Entity::insert(activity_model.into_active_model())
|
model::activity::Entity::insert(activity_model.into_active_model())
|
||||||
.exec(self.db()).await?;
|
.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?;
|
self.dispatch(&uid, activity_targets, &aid, None).await?;
|
||||||
|
|
||||||
|
@ -209,14 +148,6 @@ impl apb::server::Outbox for Context {
|
||||||
|
|
||||||
match accepted_activity.activity_type {
|
match accepted_activity.activity_type {
|
||||||
apb::ActivityType::Follow => {
|
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::Entity::insert(
|
||||||
model::relation::ActiveModel {
|
model::relation::ActiveModel {
|
||||||
follower: Set(accepted_activity.actor), following: Set(uid.clone()),
|
follower: Set(accepted_activity.actor), following: Set(uid.clone()),
|
||||||
|
@ -248,30 +179,33 @@ impl apb::server::Outbox for Context {
|
||||||
async fn undo(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
|
async fn undo(&self, uid: String, activity: serde_json::Value) -> crate::Result<String> {
|
||||||
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
let aid = self.aid(uuid::Uuid::new_v4().to_string());
|
||||||
let activity_targets = activity.addressed();
|
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)
|
let Some(old_aid) = activity.object().id() else {
|
||||||
.one(self.db())
|
return Err(UpubError::bad_request());
|
||||||
.await?
|
};
|
||||||
.ok_or_else(UpubError::not_found)?;
|
let Some(old_activity) = model::activity::Entity::find_by_id(old_aid)
|
||||||
if old_activity.actor != uid {
|
.one(self.db()).await?
|
||||||
return Err(UpubError::forbidden());
|
else {
|
||||||
}
|
return Err(UpubError::not_found());
|
||||||
match old_activity.activity_type {
|
};
|
||||||
apb::ActivityType::Like => {
|
if old_activity.actor != uid {
|
||||||
model::like::Entity::delete_many()
|
return Err(UpubError::forbidden());
|
||||||
.filter(model::like::Column::Actor.eq(old_activity.actor))
|
}
|
||||||
.filter(model::like::Column::Likes.eq(old_activity.object.unwrap_or("".into())))
|
match old_activity.activity_type {
|
||||||
.exec(self.db())
|
apb::ActivityType::Like => {
|
||||||
.await?;
|
model::like::Entity::delete(model::like::ActiveModel {
|
||||||
},
|
actor: Set(old_activity.actor), likes: Set(old_activity.object.unwrap_or("".into())),
|
||||||
apb::ActivityType::Follow => {
|
..Default::default()
|
||||||
model::relation::Entity::delete_many()
|
}).exec(self.db()).await?;
|
||||||
.filter(model::relation::Column::Follower.eq(old_activity.actor))
|
},
|
||||||
.filter(model::relation::Column::Following.eq(old_activity.object.unwrap_or("".into())))
|
apb::ActivityType::Follow => {
|
||||||
.exec(self.db())
|
model::relation::Entity::delete(model::relation::ActiveModel {
|
||||||
.await?;
|
follower: Set(old_activity.actor), following: Set(old_activity.object.unwrap_or("".into())),
|
||||||
},
|
..Default::default()
|
||||||
t => tracing::warn!("extra side effects for activity {t:?} not implemented"),
|
}).exec(self.db()).await?;
|
||||||
|
},
|
||||||
|
t => tracing::warn!("extra side effects for activity {t:?} not implemented"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let activity_model = model::activity::Model::new(
|
let activity_model = model::activity::Model::new(
|
||||||
&activity
|
&activity
|
||||||
|
@ -287,165 +221,4 @@ impl apb::server::Outbox for Context {
|
||||||
|
|
||||||
Ok(aid)
|
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
41
src/tools.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/" }
|
|
301
web/index.html
301
web/index.html
|
@ -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>
|
|
140
web/src/app.rs
140
web/src/app.rs
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>,
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
174
web/src/lib.rs
174
web/src/lib.rs
|
@ -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 { "" })
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
Loading…
Reference in a new issue