Compare commits

..

6 commits

10 changed files with 158 additions and 26 deletions

View file

@ -22,6 +22,7 @@ openssl = "0.10" # TODO handle pubkeys with a smaller crate
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.8", features = ["v4"] } uuid = { version = "1.8", features = ["v4"] }
regex = "1.10"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_default = "0.1" serde_default = "0.1"
@ -50,7 +51,7 @@ time = { version = "0.3", features = ["serde"], optional = true }
async-recursion = "1.1" async-recursion = "1.1"
[features] [features]
default = ["migrations", "cli"] default = ["mastodon", "migrations", "cli"]
cli = [] cli = []
migrations = ["dep:sea-orm-migration"] migrations = ["dep:sea-orm-migration"]
mastodon = ["dep:mastodon-async-entities", "dep:time"] mastodon = ["dep:mastodon-async-entities", "dep:time"]

View file

@ -1,6 +1,6 @@
use apb::{target::Addressed, Activity, ActivityMut, ActorMut, BaseMut, Node, Object, ObjectMut, PublicKeyMut}; use apb::{target::Addressed, Activity, ActivityMut, ActorMut, BaseMut, Node, Object, ObjectMut, PublicKeyMut};
use reqwest::StatusCode; use reqwest::StatusCode;
use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set}; use sea_orm::{sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect, SelectColumns, Set};
use crate::{errors::UpubError, model, routes::activitypub::jsonld::LD}; use crate::{errors::UpubError, model, routes::activitypub::jsonld::LD};
@ -14,15 +14,37 @@ 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 re = regex::Regex::new(r"@(\w+)@(\w+)").expect("failed compiling regex pattern");
let raw_oid = uuid::Uuid::new_v4().to_string(); let raw_oid = uuid::Uuid::new_v4().to_string();
let oid = self.oid(&raw_oid); let oid = self.oid(&raw_oid);
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 content = object.content().map(|x| x.to_string());
if let Some(c) = content {
let mut tmp = mdhtml::safe_markdown(&c);
for (full, [user, domain]) in re.captures_iter(&tmp.clone()).map(|x| x.extract()) {
if let Ok(Some(uid)) = model::user::Entity::find()
.filter(model::user::Column::PreferredUsername.eq(user))
.filter(model::user::Column::Domain.eq(domain))
.select_only()
.select_column(model::user::Column::Id)
.into_tuple::<String>()
.one(self.db())
.await
{
tmp = tmp.replacen(full, &format!("<a href=\"{uid}\" class=\"u-url mention\">@{user}</a>"), 1);
}
}
content = Some(tmp);
}
let object_model = self.insert_object( let object_model = self.insert_object(
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()))
.set_content(content.as_deref())
.set_url(Node::maybe_link(self.cfg().instance.frontend.as_ref().map(|x| format!("{x}/objects/{raw_oid}")))), .set_url(Node::maybe_link(self.cfg().instance.frontend.as_ref().map(|x| format!("{x}/objects/{raw_oid}")))),
Some(self.domain().to_string()), Some(self.domain().to_string()),
).await?; ).await?;

View file

@ -34,3 +34,5 @@ lazy_static = "1.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
web-sys = { version = "0.3", features = ["Screen"] } web-sys = { version = "0.3", features = ["Screen"] }
mdhtml = { path = "../mdhtml/" } mdhtml = { path = "../mdhtml/" }
jrd = "0.1"
tld = "2.35"

View file

@ -162,7 +162,7 @@
img { img {
max-width: 100%; max-width: 100%;
} }
img.avatar-circle { img.avatar {
display: inline; display: inline;
border-radius: 50%; border-radius: 50%;
} }
@ -170,14 +170,14 @@
background-color: var(--background); background-color: var(--background);
border: .3em solid var(--accent); border: .3em solid var(--accent);
} }
img.avatar-inline { img.inline {
display: inline;
height: .75em; height: .75em;
border-radius: 50%;
} }
img.inline-avatar { img.avatar-actor {
height: 2em; min-height: 2em;
width: 2em; max-height: 2em;
min-width: 2em;
max-width: 2em;
} }
.box { .box {
border: 3px solid var(--accent); border: 3px solid var(--accent);
@ -309,7 +309,7 @@
var(--background) 1em var(--background) 1em
); );
} }
.loader { .spinner {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
span.dots { span.dots {

View file

@ -1,7 +1,8 @@
use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut}; use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut};
use leptos::*; use leptos::*;
use crate::prelude::*; use leptos_use::DebounceOptions;
use crate::{prelude::*, WEBFINGER};
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct ReplyControls { pub struct ReplyControls {
@ -34,11 +35,34 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let reply = use_context::<ReplyControls>().expect("missing reply controls"); let reply = use_context::<ReplyControls>().expect("missing reply controls");
let (posting, set_posting) = create_signal(false); let (posting, set_posting) = create_signal(false);
let (error, set_error) = create_signal(None); let (error, set_error) = create_signal(None);
let (content, set_content) = create_signal("".to_string());
let summary_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 public_ref: NodeRef<html::Input> = create_node_ref(); let public_ref: NodeRef<html::Input> = create_node_ref();
let followers_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(); let private_ref: NodeRef<html::Input> = create_node_ref();
// TODO is this too abusive with resources? im even checking if TLD exists...
let mentions = create_local_resource(
move || content.get(),
move |c| async move {
let mut out = Vec::new();
for word in c.split(' ') {
if !word.starts_with('@') { break };
let stripped = word.replacen('@', "", 1);
if let Some((user, domain)) = stripped.split_once('@') {
if let Some(tld) = domain.split('.').last() {
if tld::exist(tld) {
if let Some(uid) = WEBFINGER.blocking_resolve(user, domain).await {
out.push(uid);
}
}
}
}
}
out
},
);
view! { view! {
<div> <div>
{move || {move ||
@ -51,13 +75,22 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
on:click=move|_| reply.clear() on:click=move|_| reply.clear()
title={format!("> {r} | ctx: {}", reply.context.get().unwrap_or_default())} title={format!("> {r} | ctx: {}", reply.context.get().unwrap_or_default())}
> >
"📨" "✒️"
</span> </span>
<small>{actor_strip}</small> {actor_strip}
<small class="tiny ml-1">"["<a class="clean" title="remove reply" href="#" on:click=move |_| reply.clear() >reply</a>"]"</small>
</span> </span>
} }
}) })
} }
{move ||
mentions.get()
.map(|x| x.into_iter().map(|u| match CACHE.get(&u) {
Some(u) => view! { <span class="nowrap"><span class="emoji mr-s ml-s">"📨"</span><ActorStrip object=u /></span> }.into_view(),
None => view! { <span class="nowrap"><span class="emoji mr-s ml-s">"📨"</span><a href={Uri::web(U::User, &u)}>{u}</a></span> }.into_view(),
})
.collect_view())
}
<table class="align w-100"> <table class="align w-100">
<tr> <tr>
<td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td> <td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td>
@ -65,7 +98,10 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
</tr> </tr>
</table> </table>
<textarea rows="6" class="w-100" node_ref=content_ref title="content" placeholder="\n look at nothing\n what do you see?" ></textarea> <textarea rows="6" class="w-100" title="content" placeholder="\n look at nothing\n what do you see?"
prop:value=content
on:input=move |ev| set_content.set(event_target_value(&ev))
></textarea>
<table class="align rev w-100"> <table class="align rev w-100">
<tr> <tr>
@ -76,7 +112,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
set_posting.set(true); set_posting.set(true);
spawn_local(async move { spawn_local(async move {
let summary = get_if_some(summary_ref); let summary = get_if_some(summary_ref);
let content = content_ref.get().map(|x| x.value()).unwrap_or_default(); let content = content.get();
let mut cc_vec = Vec::new(); let mut cc_vec = Vec::new();
let mut to_vec = Vec::new(); let mut to_vec = Vec::new();
if get_checked(followers_ref) { if get_checked(followers_ref) {
@ -92,6 +128,9 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
} }
} }
} }
for mention in mentions.get().as_deref().unwrap_or(&[]) {
to_vec.push(mention.to_string());
}
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
.set_object_type(Some(apb::ObjectType::Note)) .set_object_type(Some(apb::ObjectType::Note))
.set_summary(summary.as_deref()) .set_summary(summary.as_deref())
@ -105,7 +144,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
Ok(()) => { Ok(()) => {
set_error.set(None); set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") } if let Some(x) = summary_ref.get() { x.set_value("") }
if let Some(x) = content_ref.get() { x.set_value("") } set_content.set("".to_string());
}, },
} }
set_posting.set(false); set_posting.set(false);

View file

@ -11,7 +11,7 @@ pub fn ActorStrip(object: crate::Object) -> impl IntoView {
let avatar = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into()); let avatar = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
view! { view! {
<a href={Uri::web(U::User, &actor_id)} class="clean hover"> <a href={Uri::web(U::User, &actor_id)} class="clean hover">
<img src={avatar} class="avatar-inline mr-s" /><b>{username}</b><small>@{domain}</small> <img src={avatar} class="avatar inline mr-s" /><b>{username}</b><small>@{domain}</small>
</a> </a>
} }
} }
@ -33,7 +33,7 @@ pub fn ActorBanner(object: crate::Object) -> impl IntoView {
<div> <div>
<table class="align" > <table class="align" >
<tr> <tr>
<td rowspan="2" ><a href={uri.clone()} ><img class="avatar-circle inline-avatar" src={avatar_url} /></a></td> <td rowspan="2" ><a href={uri.clone()} ><img class="avatar avatar-actor" src={avatar_url} /></a></td>
<td><b>{display_name}</b></td> <td><b>{display_name}</b></td>
</tr> </tr>
<tr> <tr>

View file

@ -24,12 +24,13 @@ use uriproxy::UriClass;
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref CACHE: ObjectCache = ObjectCache::default(); pub static ref CACHE: ObjectCache = ObjectCache::default();
pub static ref WEBFINGER: WebfingerCache = WebfingerCache::default();
} }
pub type Object = Arc<serde_json::Value>; pub type Object = Arc<serde_json::Value>;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ObjectCache(pub Arc<dashmap::DashMap<String, Object>>); pub struct ObjectCache(Arc<dashmap::DashMap<String, Object>>);
impl ObjectCache { impl ObjectCache {
pub fn get(&self, k: &str) -> Option<Object> { pub fn get(&self, k: &str) -> Option<Object> {
@ -59,6 +60,69 @@ impl ObjectCache {
} }
} }
#[derive(Debug, Clone)]
enum LookupStatus {
Resolving,
Found(String),
NotFound,
}
#[derive(Debug, Clone, Default)]
pub struct WebfingerCache(Arc<dashmap::DashMap<String, LookupStatus>>);
impl WebfingerCache {
pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> {
if let Some(x) = self.get(user, domain) { return Some(x); }
self.fetch(user, domain).await;
self.get(user, domain)
}
pub fn resolve(&self, user: &str, domain: &str) -> Option<String> {
if let Some(x) = self.get(user, domain) { return Some(x); }
let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string());
leptos::spawn_local(async move { _self.fetch(&user, &domain).await });
None
}
fn get(&self, user: &str, domain: &str) -> Option<String> {
let query = format!("{user}@{domain}");
match self.0.get(&query).map(|x| (*x).clone())? {
LookupStatus::Resolving | LookupStatus::NotFound => None,
LookupStatus::Found(x) => Some(x),
}
}
async fn fetch(&self, user: &str, domain: &str) {
let query = format!("{user}@{domain}");
self.0.insert(query.to_string(), LookupStatus::Resolving);
match reqwest::get(format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}")).await {
Ok(res) => match res.error_for_status() {
Ok(res) => match res.json::<jrd::JsonResourceDescriptor>().await {
Ok(doc) => {
if let Some(uid) = doc.links.into_iter().find(|x| x.rel == "self").map(|x| x.href).flatten() {
self.0.insert(query, LookupStatus::Found(uid));
} else {
self.0.insert(query, LookupStatus::NotFound);
}
},
Err(e) => {
tracing::error!("invalid webfinger response: {e:?}");
self.0.remove(&query);
},
},
Err(e) => {
tracing::error!("could not resolve webfinbger: {e:?}");
self.0.insert(query, LookupStatus::NotFound);
},
},
Err(e) => {
tracing::error!("failed accessing webfinger server: {e:?}");
self.0.remove(&query);
},
}
}
}
pub struct Http; pub struct Http;

View file

@ -7,7 +7,8 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
let (color, set_color) = leptos_use::use_css_var("--accent"); let (color, set_color) = leptos_use::use_css_var("--accent");
let (_color_rgb, set_color_rgb) = leptos_use::use_css_var("--accent-rgb"); let (_color_rgb, set_color_rgb) = leptos_use::use_css_var("--accent-rgb");
let previous_color = config.get().accent_color; // TODO should this be responsive? idk
let previous_color = config.get_untracked().accent_color;
set_color_rgb.set(parse_hex(&previous_color)); set_color_rgb.set(parse_hex(&previous_color));
set_color.set(previous_color); set_color.set(previous_color);
@ -91,7 +92,7 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
<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> <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> </ul>
<hr /> <hr />
<p><a href="/web/config/dev" title="access the devtools page">devtools</a></p> <p class="center"><a href="/web/config/dev" title="access the devtools page">devtools</a></p>
</div> </div>
} }
} }

View file

@ -20,11 +20,15 @@ pub fn DebugPage() -> impl IntoView {
cached_query, cached_query,
move |(query, cached)| async move { move |(query, cached)| async move {
set_text.set(query.clone()); set_text.set(query.clone());
set_error.set(false);
if query.is_empty() { return serde_json::Value::Null }; if query.is_empty() { return serde_json::Value::Null };
if cached { if cached {
match CACHE.get(&query) { match CACHE.get(&query) {
Some(x) => (*x).clone(), Some(x) => (*x).clone(),
None => serde_json::Value::Null, None => {
set_error.set(true);
serde_json::Value::Null
},
} }
} else { } else {
debug_fetch(&format!("{URL_BASE}/proxy?id={query}"), auth, set_error).await debug_fetch(&format!("{URL_BASE}/proxy?id={query}"), auth, set_error).await
@ -56,7 +60,7 @@ pub fn DebugPage() -> impl IntoView {
</td> </td>
<td> <td>
<input type="checkbox" title="load from local cache" value="cached" <input type="checkbox" title="load from local cache" value="cached"
class:loader=loading class:spinner=loading
prop:checked=cached prop:checked=cached
on:input=move |ev| set_cached.set(event_target_checked(&ev)) on:input=move |ev| set_cached.set(event_target_checked(&ev))
/> />
@ -98,7 +102,6 @@ pub fn DebugPage() -> impl IntoView {
// this is a rather weird way to fetch but i want to see the bare error text if it fails! // 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, error: WriteSignal<bool>) -> serde_json::Value { async fn debug_fetch(url: &str, token: Auth, error: WriteSignal<bool>) -> serde_json::Value {
error.set(false);
match Http::request::<()>(reqwest::Method::GET, url, None, token).await { match Http::request::<()>(reqwest::Method::GET, url, None, token).await {
Ok(res) => { Ok(res) => {
if res.error_for_status_ref().is_err() { if res.error_for_status_ref().is_err() {

View file

@ -104,7 +104,7 @@ pub fn UserPage(tl: Timeline) -> impl IntoView {
<table class="pl-2 pr-2 align w-100" style="table-layout: fixed"> <table class="pl-2 pr-2 align w-100" style="table-layout: fixed">
<tr> <tr>
<td rowspan=4 style="width: 8em"> <td rowspan=4 style="width: 8em">
<img class="avatar-circle avatar-border mr-s" src={avatar_url} style="height: 7em; width: 7em"/> <img class="avatar avatar-border mr-s" src={avatar_url} style="height: 7em; width: 7em"/>
</td> </td>
<td rowspan=2 class="bottom"> <td rowspan=2 class="bottom">
<b class="big">{display_name}</b>{actor_type_tag} <b class="big">{display_name}</b>{actor_type_tag}