mirror of
https://github.com/nullishamy/ferri.git
synced 2025-08-17 18:19:14 +00:00
Compare commits
2 commits
90be7d570e
...
ab9836293e
Author | SHA1 | Date | |
---|---|---|---|
ab9836293e | |||
fafaf243c5 |
29 changed files with 1167 additions and 1618 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
use main::types::{db, make, ObjectUri, ObjectUuid};
|
||||||
use server::launch;
|
use server::launch;
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
|
|
||||||
|
@ -24,6 +25,10 @@ pub fn read_config(path: impl AsRef<Path>) -> config::Config {
|
||||||
toml::from_str(&content).unwrap()
|
toml::from_str(&content).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn s(st: &'static str) -> String {
|
||||||
|
st.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
@ -36,42 +41,32 @@ async fn main() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut conn = pool.acquire().await.unwrap();
|
let mut conn = pool.acquire().await.unwrap();
|
||||||
|
|
||||||
sqlx::query!(
|
let actor = db::Actor {
|
||||||
r#"
|
id: ObjectUri(s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9")),
|
||||||
INSERT INTO actor (id, inbox, outbox)
|
inbox: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/inbox"),
|
||||||
VALUES (?1, ?2, ?3)
|
outbox: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/outbox")
|
||||||
"#,
|
};
|
||||||
"https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9",
|
|
||||||
"https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/inbox",
|
|
||||||
"https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9/outbox"
|
|
||||||
)
|
|
||||||
.execute(&mut *conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ts = main::ap::new_ts();
|
make::new_actor(actor.clone(), &mut *conn).await.unwrap();
|
||||||
|
|
||||||
sqlx::query!(
|
let user = db::User {
|
||||||
r#"
|
id: ObjectUuid(s("9b9d497b-2731-435f-a929-e609ca69dac9")),
|
||||||
INSERT INTO user (
|
actor,
|
||||||
id, acct, url, remote, username,
|
username: s("amy"),
|
||||||
actor_id, display_name, created_at, icon_url
|
display_name: s("amy (display)"),
|
||||||
)
|
acct: s("amy"),
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
remote: false,
|
||||||
"#,
|
url: s("https://ferri.amy.mov/@amy"),
|
||||||
"9b9d497b-2731-435f-a929-e609ca69dac9",
|
created_at: main::now(),
|
||||||
"amy",
|
icon_url: s("https://ferri.amy.mov/assets/pfp.png"),
|
||||||
"https://ferri.amy.mov/@amy",
|
posts: db::UserPosts {
|
||||||
false,
|
last_post_at: None
|
||||||
"amy",
|
},
|
||||||
"https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9",
|
key_id: s("https://ferri.amy.mov/users/9b9d497b-2731-435f-a929-e609ca69dac9#main-key")
|
||||||
"amy",
|
|
||||||
ts,
|
};
|
||||||
"https://ferri.amy.mov/assets/pfp.png"
|
|
||||||
)
|
make::new_user(user, &mut *conn).await.unwrap();
|
||||||
.execute(&mut *conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
} else {
|
} else {
|
||||||
let _ = launch(config).launch().await;
|
let _ = launch(config).launch().await;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
use crate::ap::{Actor, User, http};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::Sqlite;
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use tracing::{Level, event};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum ActivityType {
|
|
||||||
Follow,
|
|
||||||
Accept,
|
|
||||||
Create,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActivityType {
|
|
||||||
fn to_raw(self) -> String {
|
|
||||||
match self {
|
|
||||||
ActivityType::Follow => "Follow".to_string(),
|
|
||||||
ActivityType::Accept => "Accept".to_string(),
|
|
||||||
ActivityType::Create => "Create".to_string(),
|
|
||||||
ActivityType::Unknown => "FIXME".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Activity<T: Serialize + Debug> {
|
|
||||||
pub id: String,
|
|
||||||
pub ty: ActivityType,
|
|
||||||
pub object: T,
|
|
||||||
pub published: DateTime<Utc>,
|
|
||||||
pub to: Vec<String>,
|
|
||||||
pub cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Serialize + Debug + Default> Default for Activity<T> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
id: Default::default(),
|
|
||||||
ty: ActivityType::Unknown,
|
|
||||||
object: Default::default(),
|
|
||||||
published: Utc::now(),
|
|
||||||
to: Default::default(),
|
|
||||||
cc: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type KeyId = String;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OutgoingActivity<T: Serialize + Debug> {
|
|
||||||
pub signed_by: KeyId,
|
|
||||||
pub req: Activity<T>,
|
|
||||||
pub to: Actor,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Serialize + Debug> OutgoingActivity<T> {
|
|
||||||
pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
|
||||||
let ty = self.req.ty.clone().to_raw();
|
|
||||||
let actor_id = self.to.id();
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO activity (id, ty, actor_id)
|
|
||||||
VALUES (?1, ?2, ?3)
|
|
||||||
"#,
|
|
||||||
self.req.id,
|
|
||||||
ty,
|
|
||||||
actor_id
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
struct RawActivity<T: Serialize + Debug> {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
#[serde(skip_deserializing)]
|
|
||||||
context: String,
|
|
||||||
|
|
||||||
id: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
ty: String,
|
|
||||||
|
|
||||||
actor: String,
|
|
||||||
object: T,
|
|
||||||
published: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutboxTransport = http::HttpClient;
|
|
||||||
pub struct Outbox<'a> {
|
|
||||||
user: User,
|
|
||||||
transport: &'a OutboxTransport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Outbox<'a> {
|
|
||||||
pub fn user(&self) -> &User {
|
|
||||||
&self.user
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post<T: Serialize + Debug>(&self, activity: OutgoingActivity<T>) {
|
|
||||||
event!(Level::INFO, ?activity, "activity in outbox");
|
|
||||||
|
|
||||||
let raw = RawActivity {
|
|
||||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
|
||||||
id: activity.req.id.clone(),
|
|
||||||
ty: activity.req.ty.to_raw(),
|
|
||||||
actor: self.user.actor().id().to_string(),
|
|
||||||
object: activity.req.object,
|
|
||||||
published: activity.req.published.to_rfc3339(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let outbox_res = self
|
|
||||||
.transport
|
|
||||||
.post(activity.to.inbox())
|
|
||||||
.activity()
|
|
||||||
.json(&raw)
|
|
||||||
.sign(&activity.signed_by)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
event!(
|
|
||||||
Level::DEBUG,
|
|
||||||
outbox_res,
|
|
||||||
activity = activity.req.id,
|
|
||||||
"got response for outbox dispatch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn for_user(user: User, transport: &'a OutboxTransport) -> Outbox<'a> {
|
|
||||||
Outbox { user, transport }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,199 +0,0 @@
|
||||||
use reqwest::{IntoUrl, Response};
|
|
||||||
use serde::Serialize;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use rsa::{
|
|
||||||
RsaPrivateKey,
|
|
||||||
pkcs1v15::SigningKey,
|
|
||||||
pkcs8::DecodePrivateKey,
|
|
||||||
sha2::{Digest, Sha256},
|
|
||||||
signature::{RandomizedSigner, SignatureEncoding},
|
|
||||||
};
|
|
||||||
|
|
||||||
use base64::prelude::*;
|
|
||||||
use chrono::Utc;
|
|
||||||
use tracing::{Level, event};
|
|
||||||
|
|
||||||
pub struct HttpClient {
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PostSignature {
|
|
||||||
date: String,
|
|
||||||
digest: String,
|
|
||||||
signature: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct GetSignature {
|
|
||||||
date: String,
|
|
||||||
signature: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RequestVerb {
|
|
||||||
GET,
|
|
||||||
POST,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RequestBuilder {
|
|
||||||
verb: RequestVerb,
|
|
||||||
url: Url,
|
|
||||||
body: String,
|
|
||||||
inner: reqwest::RequestBuilder,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestBuilder {
|
|
||||||
pub fn json(mut self, json: impl Serialize + Sized) -> RequestBuilder {
|
|
||||||
let body = serde_json::to_string(&json).unwrap();
|
|
||||||
self.inner = self.inner.body(body.clone());
|
|
||||||
self.body = body;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activity(mut self) -> RequestBuilder {
|
|
||||||
self.inner = self
|
|
||||||
.inner
|
|
||||||
.header("Content-Type", "application/activity+json")
|
|
||||||
.header("Accept", "application/activity+json");
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(self) -> Result<Response, reqwest::Error> {
|
|
||||||
event!(Level::DEBUG, ?self.inner, "sending an http request");
|
|
||||||
|
|
||||||
self.inner.send().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sign(mut self, key_id: &str) -> RequestBuilder {
|
|
||||||
match self.verb {
|
|
||||||
RequestVerb::GET => {
|
|
||||||
let sig = self.sign_get_request(key_id);
|
|
||||||
self.inner = self
|
|
||||||
.inner
|
|
||||||
.header("Date", sig.date)
|
|
||||||
.header("Signature", sig.signature);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
RequestVerb::POST => {
|
|
||||||
let sig = self.sign_post_request(key_id);
|
|
||||||
self.inner = self
|
|
||||||
.inner
|
|
||||||
.header("Date", sig.date)
|
|
||||||
.header("Digest", sig.digest)
|
|
||||||
.header("Signature", sig.signature);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign_get_request(&self, key_id: &str) -> GetSignature {
|
|
||||||
let url = &self.url;
|
|
||||||
let host = url.host_str().unwrap();
|
|
||||||
let path = url.path();
|
|
||||||
|
|
||||||
let private_key =
|
|
||||||
RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap();
|
|
||||||
let signing_key = SigningKey::<Sha256>::new(private_key);
|
|
||||||
|
|
||||||
// UTC=GMT for our purposes, use it
|
|
||||||
// RFC7231 is hardcoded to use GMT for.. some reason
|
|
||||||
let ts = Utc::now();
|
|
||||||
|
|
||||||
// RFC7231 string
|
|
||||||
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
|
||||||
|
|
||||||
let to_sign = format!(
|
|
||||||
"(request-target): get {}\nhost: {}\ndate: {}",
|
|
||||||
path, host, date
|
|
||||||
);
|
|
||||||
|
|
||||||
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
|
||||||
let header = format!(
|
|
||||||
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"",
|
|
||||||
key_id,
|
|
||||||
BASE64_STANDARD.encode(signature.to_bytes())
|
|
||||||
);
|
|
||||||
|
|
||||||
GetSignature {
|
|
||||||
date,
|
|
||||||
signature: header,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sign_post_request(&self, key_id: &str) -> PostSignature {
|
|
||||||
let body = &self.body;
|
|
||||||
let url = &self.url;
|
|
||||||
|
|
||||||
let host = url.host_str().unwrap();
|
|
||||||
let path = url.path();
|
|
||||||
|
|
||||||
let private_key =
|
|
||||||
RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap();
|
|
||||||
let signing_key = SigningKey::<Sha256>::new(private_key);
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(body);
|
|
||||||
let sha256 = hasher.finalize();
|
|
||||||
|
|
||||||
let b64 = BASE64_STANDARD.encode(sha256);
|
|
||||||
let digest = format!("SHA-256={}", b64);
|
|
||||||
|
|
||||||
// UTC=GMT for our purposes, use it
|
|
||||||
// RFC7231 is hardcoded to use GMT for.. some reason
|
|
||||||
let ts = Utc::now();
|
|
||||||
|
|
||||||
// RFC7231 string
|
|
||||||
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
|
||||||
|
|
||||||
let to_sign = format!(
|
|
||||||
"(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}",
|
|
||||||
path, host, date, digest
|
|
||||||
);
|
|
||||||
|
|
||||||
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
|
||||||
let header = format!(
|
|
||||||
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"",
|
|
||||||
key_id,
|
|
||||||
BASE64_STANDARD.encode(signature.to_bytes())
|
|
||||||
);
|
|
||||||
|
|
||||||
PostSignature {
|
|
||||||
date,
|
|
||||||
digest,
|
|
||||||
signature: header,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for HttpClient {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HttpClient {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, url: impl IntoUrl + Clone) -> RequestBuilder {
|
|
||||||
RequestBuilder {
|
|
||||||
verb: RequestVerb::GET,
|
|
||||||
url: url.clone().into_url().unwrap(),
|
|
||||||
body: String::new(),
|
|
||||||
inner: self.client.get(url),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn post(&self, url: impl IntoUrl + Clone) -> RequestBuilder {
|
|
||||||
RequestBuilder {
|
|
||||||
verb: RequestVerb::POST,
|
|
||||||
url: url.clone().into_url().unwrap(),
|
|
||||||
body: String::new(),
|
|
||||||
inner: self.client.post(url),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub mod http;
|
|
||||||
|
|
||||||
mod activity;
|
|
||||||
pub use activity::*;
|
|
||||||
|
|
||||||
mod user;
|
|
||||||
pub use user::*;
|
|
||||||
|
|
||||||
mod post;
|
|
||||||
pub use post::*;
|
|
||||||
|
|
||||||
mod request_queue;
|
|
||||||
pub use request_queue::*;
|
|
||||||
|
|
||||||
pub const AS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
|
|
||||||
|
|
||||||
pub fn new_id() -> String {
|
|
||||||
Uuid::new_v4().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_ts() -> String {
|
|
||||||
now().to_rfc3339()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now() -> DateTime<Utc> {
|
|
||||||
Utc::now()
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
use crate::ap;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::Serialize;
|
|
||||||
use sqlx::Sqlite;
|
|
||||||
|
|
||||||
const POST_TYPE: &str = "Note";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Post {
|
|
||||||
id: String,
|
|
||||||
from: ap::User,
|
|
||||||
ts: DateTime<Utc>,
|
|
||||||
content: String,
|
|
||||||
|
|
||||||
to: Vec<String>,
|
|
||||||
cc: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Post {
|
|
||||||
pub fn from_parts(id: String, content: String, from: ap::User) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
content,
|
|
||||||
from,
|
|
||||||
ts: ap::now(),
|
|
||||||
to: vec![],
|
|
||||||
cc: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn content(&self) -> &str {
|
|
||||||
&self.content
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn created_at(&self) -> String {
|
|
||||||
self.ts.to_rfc3339()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uri(&self) -> String {
|
|
||||||
format!(
|
|
||||||
"https://ferri.amy.mov/users/{}/posts/{}",
|
|
||||||
self.from.id(),
|
|
||||||
self.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save(&self, conn: impl sqlx::Executor<'_, Database = Sqlite>) {
|
|
||||||
let ts = self.ts.to_rfc3339();
|
|
||||||
let user_id = self.from.id();
|
|
||||||
let post_id = self.id();
|
|
||||||
let uri = self.uri();
|
|
||||||
let content = self.content.clone();
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO post (id, uri, user_id, content, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
||||||
"#,
|
|
||||||
post_id,
|
|
||||||
uri,
|
|
||||||
user_id,
|
|
||||||
content,
|
|
||||||
ts
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to(mut self, recipient: String) -> Self {
|
|
||||||
self.to.push(recipient);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cc(mut self, recipient: String) -> Self {
|
|
||||||
self.cc.push(recipient);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_ap(self) -> APPost {
|
|
||||||
APPost {
|
|
||||||
context: ap::AS_CONTEXT.to_string(),
|
|
||||||
id: self.uri(),
|
|
||||||
ty: POST_TYPE.to_string(),
|
|
||||||
ts: self.ts.to_rfc3339(),
|
|
||||||
content: self.content,
|
|
||||||
to: self.to,
|
|
||||||
cc: self.cc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug, Default)]
|
|
||||||
pub struct APPost {
|
|
||||||
#[serde(rename = "@context")]
|
|
||||||
#[serde(skip_deserializing)]
|
|
||||||
context: String,
|
|
||||||
id: String,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
ty: String,
|
|
||||||
|
|
||||||
#[serde(rename = "published")]
|
|
||||||
ts: String,
|
|
||||||
|
|
||||||
content: String,
|
|
||||||
to: Vec<String>,
|
|
||||||
cc: Vec<String>,
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
use tracing::{Level, info, span};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum QueueMessage {
|
|
||||||
Heartbeat,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RequestQueue {
|
|
||||||
name: &'static str,
|
|
||||||
send: mpsc::Sender<QueueMessage>,
|
|
||||||
recv: mpsc::Receiver<QueueMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct QueueHandle {
|
|
||||||
send: mpsc::Sender<QueueMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl QueueHandle {
|
|
||||||
pub fn send(&self, msg: QueueMessage) {
|
|
||||||
self.send.send(msg).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestQueue {
|
|
||||||
pub fn new(name: &'static str) -> Self {
|
|
||||||
let (send, recv) = mpsc::channel();
|
|
||||||
Self { name, send, recv }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn(self) -> QueueHandle {
|
|
||||||
info!("starting up queue '{}'", self.name);
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
info!("queue '{}' up", self.name);
|
|
||||||
let recv = self.recv;
|
|
||||||
|
|
||||||
while let Ok(req) = recv.recv() {
|
|
||||||
// FIXME: When we make this do async things we will need to add tokio and
|
|
||||||
// use proper async handled spans as the enter/drop won't work.
|
|
||||||
// See inbox.rs for how we handle that.
|
|
||||||
let s = span!(Level::INFO, "queue", queue_name = self.name);
|
|
||||||
let _enter = s.enter();
|
|
||||||
|
|
||||||
info!(?req, "got a message into the queue");
|
|
||||||
|
|
||||||
drop(_enter);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
QueueHandle { send: self.send }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,154 +0,0 @@
|
||||||
use sqlx::Sqlite;
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Actor {
|
|
||||||
id: String,
|
|
||||||
inbox: String,
|
|
||||||
outbox: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Actor {
|
|
||||||
pub fn from_raw(id: String, inbox: String, outbox: String) -> Self {
|
|
||||||
Self { id, inbox, outbox }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inbox(&self) -> &str {
|
|
||||||
&self.inbox
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn outbox(&self) -> &str {
|
|
||||||
&self.outbox
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct User {
|
|
||||||
id: String,
|
|
||||||
username: String,
|
|
||||||
actor: Actor,
|
|
||||||
display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum UserError {
|
|
||||||
#[error("user `{0}` not found")]
|
|
||||||
NotFound(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl User {
|
|
||||||
pub fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn username(&self) -> &str {
|
|
||||||
&self.username
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn actor_id(&self) -> &str {
|
|
||||||
&self.actor.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn display_name(&self) -> &str {
|
|
||||||
&self.display_name
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn actor(&self) -> &Actor {
|
|
||||||
&self.actor
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uri(&self) -> String {
|
|
||||||
format!("https://ferri.amy.mov/users/{}", self.id())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn from_id(
|
|
||||||
uuid: &str,
|
|
||||||
conn: impl sqlx::Executor<'_, Database = Sqlite>,
|
|
||||||
) -> Result<User, UserError> {
|
|
||||||
let user = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox
|
|
||||||
FROM user u
|
|
||||||
INNER JOIN actor a ON u.actor_id = a.id
|
|
||||||
WHERE u.id = ?1
|
|
||||||
"#,
|
|
||||||
uuid
|
|
||||||
)
|
|
||||||
.fetch_one(conn)
|
|
||||||
.await
|
|
||||||
.map_err(|_| UserError::NotFound(uuid.to_string()))?;
|
|
||||||
|
|
||||||
Ok(User {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
actor: Actor {
|
|
||||||
id: user.actor_own_id,
|
|
||||||
inbox: user.inbox,
|
|
||||||
outbox: user.outbox,
|
|
||||||
},
|
|
||||||
display_name: user.display_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn from_username(
|
|
||||||
username: &str,
|
|
||||||
conn: impl sqlx::Executor<'_, Database = Sqlite>,
|
|
||||||
) -> User {
|
|
||||||
let user = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox
|
|
||||||
FROM user u
|
|
||||||
INNER JOIN actor a ON u.actor_id = a.id
|
|
||||||
WHERE username = ?1
|
|
||||||
"#,
|
|
||||||
username
|
|
||||||
)
|
|
||||||
.fetch_one(conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
User {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
actor: Actor {
|
|
||||||
id: user.actor_own_id,
|
|
||||||
inbox: user.inbox,
|
|
||||||
outbox: user.outbox,
|
|
||||||
},
|
|
||||||
display_name: user.display_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn from_actor_id(
|
|
||||||
actor_id: &str,
|
|
||||||
conn: impl sqlx::Executor<'_, Database = Sqlite>,
|
|
||||||
) -> User {
|
|
||||||
let user = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox
|
|
||||||
FROM user u
|
|
||||||
INNER JOIN actor a ON u.actor_id = a.id
|
|
||||||
WHERE actor_id = ?1
|
|
||||||
"#,
|
|
||||||
actor_id
|
|
||||||
)
|
|
||||||
.fetch_one(conn)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
User {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
actor: Actor {
|
|
||||||
id: user.actor_own_id,
|
|
||||||
inbox: user.inbox,
|
|
||||||
outbox: user.outbox,
|
|
||||||
},
|
|
||||||
display_name: user.display_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,23 @@
|
||||||
use crate::ap::http::HttpClient;
|
|
||||||
use crate::types::ap;
|
use crate::types::ap;
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{Level, error, event, info};
|
use tracing::{Level, error, event, info};
|
||||||
|
|
||||||
|
use reqwest::{IntoUrl, Response};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use rsa::{
|
||||||
|
RsaPrivateKey,
|
||||||
|
pkcs1v15::SigningKey,
|
||||||
|
pkcs8::DecodePrivateKey,
|
||||||
|
sha2::{Digest, Sha256},
|
||||||
|
signature::{RandomizedSigner, SignatureEncoding},
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::prelude::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
use super::outbox::PreparedActivity;
|
use super::outbox::PreparedActivity;
|
||||||
|
|
||||||
pub struct HttpWrapper<'a> {
|
pub struct HttpWrapper<'a> {
|
||||||
|
@ -108,3 +121,187 @@ impl<'a> HttpWrapper<'a> {
|
||||||
Ok(raw_body.to_string())
|
Ok(raw_body.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PostSignature {
|
||||||
|
date: String,
|
||||||
|
digest: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct GetSignature {
|
||||||
|
date: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestVerb {
|
||||||
|
GET,
|
||||||
|
POST,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RequestBuilder {
|
||||||
|
verb: RequestVerb,
|
||||||
|
url: Url,
|
||||||
|
body: String,
|
||||||
|
inner: reqwest::RequestBuilder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestBuilder {
|
||||||
|
pub fn json(mut self, json: impl Serialize + Sized) -> RequestBuilder {
|
||||||
|
let body = serde_json::to_string(&json).unwrap();
|
||||||
|
self.inner = self.inner.body(body.clone());
|
||||||
|
self.body = body;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activity(mut self) -> RequestBuilder {
|
||||||
|
self.inner = self
|
||||||
|
.inner
|
||||||
|
.header("Content-Type", "application/activity+json")
|
||||||
|
.header("Accept", "application/activity+json");
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(self) -> Result<Response, reqwest::Error> {
|
||||||
|
event!(Level::DEBUG, ?self.inner, "sending an http request");
|
||||||
|
|
||||||
|
self.inner.send().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(mut self, key_id: &str) -> RequestBuilder {
|
||||||
|
match self.verb {
|
||||||
|
RequestVerb::GET => {
|
||||||
|
let sig = self.sign_get_request(key_id);
|
||||||
|
self.inner = self
|
||||||
|
.inner
|
||||||
|
.header("Date", sig.date)
|
||||||
|
.header("Signature", sig.signature);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
RequestVerb::POST => {
|
||||||
|
let sig = self.sign_post_request(key_id);
|
||||||
|
self.inner = self
|
||||||
|
.inner
|
||||||
|
.header("Date", sig.date)
|
||||||
|
.header("Digest", sig.digest)
|
||||||
|
.header("Signature", sig.signature);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_get_request(&self, key_id: &str) -> GetSignature {
|
||||||
|
let url = &self.url;
|
||||||
|
let host = url.host_str().unwrap();
|
||||||
|
let path = url.path();
|
||||||
|
|
||||||
|
let private_key =
|
||||||
|
RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap();
|
||||||
|
let signing_key = SigningKey::<Sha256>::new(private_key);
|
||||||
|
|
||||||
|
// UTC=GMT for our purposes, use it
|
||||||
|
// RFC7231 is hardcoded to use GMT for.. some reason
|
||||||
|
let ts = Utc::now();
|
||||||
|
|
||||||
|
// RFC7231 string
|
||||||
|
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||||
|
|
||||||
|
let to_sign = format!(
|
||||||
|
"(request-target): get {}\nhost: {}\ndate: {}",
|
||||||
|
path, host, date
|
||||||
|
);
|
||||||
|
|
||||||
|
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
||||||
|
let header = format!(
|
||||||
|
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"",
|
||||||
|
key_id,
|
||||||
|
BASE64_STANDARD.encode(signature.to_bytes())
|
||||||
|
);
|
||||||
|
|
||||||
|
GetSignature {
|
||||||
|
date,
|
||||||
|
signature: header,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_post_request(&self, key_id: &str) -> PostSignature {
|
||||||
|
let body = &self.body;
|
||||||
|
let url = &self.url;
|
||||||
|
|
||||||
|
let host = url.host_str().unwrap();
|
||||||
|
let path = url.path();
|
||||||
|
|
||||||
|
let private_key =
|
||||||
|
RsaPrivateKey::from_pkcs8_pem(include_str!("../../../private.pem")).unwrap();
|
||||||
|
let signing_key = SigningKey::<Sha256>::new(private_key);
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(body);
|
||||||
|
let sha256 = hasher.finalize();
|
||||||
|
|
||||||
|
let b64 = BASE64_STANDARD.encode(sha256);
|
||||||
|
let digest = format!("SHA-256={}", b64);
|
||||||
|
|
||||||
|
// UTC=GMT for our purposes, use it
|
||||||
|
// RFC7231 is hardcoded to use GMT for.. some reason
|
||||||
|
let ts = Utc::now();
|
||||||
|
|
||||||
|
// RFC7231 string
|
||||||
|
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||||
|
|
||||||
|
let to_sign = format!(
|
||||||
|
"(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}",
|
||||||
|
path, host, date, digest
|
||||||
|
);
|
||||||
|
|
||||||
|
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
|
||||||
|
let header = format!(
|
||||||
|
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"",
|
||||||
|
key_id,
|
||||||
|
BASE64_STANDARD.encode(signature.to_bytes())
|
||||||
|
);
|
||||||
|
|
||||||
|
PostSignature {
|
||||||
|
date,
|
||||||
|
digest,
|
||||||
|
signature: header,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, url: impl IntoUrl + Clone) -> RequestBuilder {
|
||||||
|
RequestBuilder {
|
||||||
|
verb: RequestVerb::GET,
|
||||||
|
url: url.clone().into_url().unwrap(),
|
||||||
|
body: String::new(),
|
||||||
|
inner: self.client.get(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn post(&self, url: impl IntoUrl + Clone) -> RequestBuilder {
|
||||||
|
RequestBuilder {
|
||||||
|
verb: RequestVerb::POST,
|
||||||
|
url: url.clone().into_url().unwrap(),
|
||||||
|
body: String::new(),
|
||||||
|
inner: self.client.post(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
use crate::types::{ap, as_context, db, get, make, Object, ObjectUri, ObjectUuid};
|
use crate::types::{ap, as_context, db, get, make, Object, ObjectUri, ObjectUuid};
|
||||||
use crate::ap::http::HttpClient;
|
|
||||||
|
|
||||||
use super::http::HttpWrapper;
|
use super::http::{HttpClient, HttpWrapper};
|
||||||
use super::outbox::OutboxRequest;
|
use super::outbox::OutboxRequest;
|
||||||
use super::QueueMessage;
|
use super::QueueMessage;
|
||||||
|
|
||||||
|
@ -83,7 +82,7 @@ pub async fn handle_inbox_request(
|
||||||
let rmt = person.remote_info();
|
let rmt = person.remote_info();
|
||||||
|
|
||||||
let post = activity.object;
|
let post = activity.object;
|
||||||
let post_id = crate::ap::new_id();
|
let post_id = crate::new_id();
|
||||||
|
|
||||||
let created_at = DateTime::parse_from_rfc3339(&activity.ts)
|
let created_at = DateTime::parse_from_rfc3339(&activity.ts)
|
||||||
.map(|dt| dt.to_utc())
|
.map(|dt| dt.to_utc())
|
||||||
|
@ -104,20 +103,25 @@ pub async fn handle_inbox_request(
|
||||||
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
|
let id = crate::new_id();
|
||||||
db::User {
|
db::User {
|
||||||
id: ObjectUuid(crate::new_id()),
|
id: ObjectUuid(id.clone()),
|
||||||
actor,
|
actor,
|
||||||
username: person.preferred_username,
|
username: person.preferred_username,
|
||||||
display_name: person.name,
|
display_name: person.name,
|
||||||
acct: rmt.acct,
|
acct: rmt.acct,
|
||||||
remote: rmt.is_remote,
|
remote: rmt.is_remote,
|
||||||
url: rmt.web_url,
|
url: rmt.web_url,
|
||||||
created_at: crate::ap::now(),
|
created_at: crate::now(),
|
||||||
icon_url: person.icon.map(|ic| ic.url)
|
icon_url: person.icon.map(|ic| ic.url)
|
||||||
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
||||||
posts: db::UserPosts {
|
posts: db::UserPosts {
|
||||||
last_post_at: None
|
last_post_at: None
|
||||||
}
|
},
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -199,8 +203,9 @@ pub async fn handle_inbox_request(
|
||||||
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
|
let id = crate::new_id();
|
||||||
db::User {
|
db::User {
|
||||||
id: ObjectUuid(crate::new_id()),
|
id: ObjectUuid(id.clone()),
|
||||||
actor,
|
actor,
|
||||||
username: boosted_author.preferred_username,
|
username: boosted_author.preferred_username,
|
||||||
display_name: boosted_author.name,
|
display_name: boosted_author.name,
|
||||||
|
@ -208,12 +213,16 @@ pub async fn handle_inbox_request(
|
||||||
remote: boosted_rmt.is_remote,
|
remote: boosted_rmt.is_remote,
|
||||||
url: boosted_rmt.web_url,
|
url: boosted_rmt.web_url,
|
||||||
// FIXME: Come from boosted_author
|
// FIXME: Come from boosted_author
|
||||||
created_at: crate::ap::now(),
|
created_at: crate::now(),
|
||||||
icon_url: boosted_author.icon.map(|ic| ic.url)
|
icon_url: boosted_author.icon.map(|ic| ic.url)
|
||||||
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
||||||
posts: db::UserPosts {
|
posts: db::UserPosts {
|
||||||
last_post_at: None
|
last_post_at: None
|
||||||
}
|
},
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -270,20 +279,25 @@ pub async fn handle_inbox_request(
|
||||||
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
|
let id = crate::new_id();
|
||||||
db::User {
|
db::User {
|
||||||
id: ObjectUuid(crate::new_id()),
|
id: ObjectUuid(id.clone()),
|
||||||
actor,
|
actor,
|
||||||
username: person.preferred_username,
|
username: person.preferred_username,
|
||||||
display_name: person.name,
|
display_name: person.name,
|
||||||
acct: rmt.acct,
|
acct: rmt.acct,
|
||||||
remote: rmt.is_remote,
|
remote: rmt.is_remote,
|
||||||
url: rmt.web_url,
|
url: rmt.web_url,
|
||||||
created_at: crate::ap::now(),
|
created_at: crate::now(),
|
||||||
icon_url: person.icon.map(|ic| ic.url)
|
icon_url: person.icon.map(|ic| ic.url)
|
||||||
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
|
||||||
posts: db::UserPosts {
|
posts: db::UserPosts {
|
||||||
last_post_at: None
|
last_post_at: None
|
||||||
}
|
},
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use crate::{ap::http::HttpClient, federation::http::HttpWrapper, types::{ap::{self, ActivityType}, as_context, db, Object, ObjectContext, ObjectUri}};
|
use crate::{federation::http::HttpWrapper, types::{ap::{self, ActivityType}, as_context, db, make, Object, ObjectContext, ObjectUri}};
|
||||||
|
|
||||||
|
use super::http::HttpClient;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum OutboxRequest {
|
pub enum OutboxRequest {
|
||||||
// FIXME: Make the String (key_id) nicer
|
// FIXME: Make the String (key_id) nicer
|
||||||
// Probably store it in the DB and pass a db::User here
|
// Probably store it in the DB and pass a db::User here
|
||||||
Accept(ap::AcceptActivity, String, ap::Person),
|
Accept(ap::AcceptActivity, String, ap::Person),
|
||||||
Status(db::Post, String)
|
Status(db::Post, String),
|
||||||
|
Follow {
|
||||||
|
follower: db::User,
|
||||||
|
followed: db::User,
|
||||||
|
conn: SqliteConnection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
@ -40,7 +48,7 @@ pub async fn handle_outbox_request(
|
||||||
ty: activity.ty,
|
ty: activity.ty,
|
||||||
actor: activity.actor,
|
actor: activity.actor,
|
||||||
object: activity.object,
|
object: activity.object,
|
||||||
published: crate::ap::new_ts()
|
published: crate::now_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = http
|
let res = http
|
||||||
|
@ -80,7 +88,7 @@ pub async fn handle_outbox_request(
|
||||||
attachment: vec![],
|
attachment: vec![],
|
||||||
attributed_to: Some(post.user.actor.id.0)
|
attributed_to: Some(post.user.actor.id.0)
|
||||||
},
|
},
|
||||||
published: crate::ap::new_ts()
|
published: crate::now_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = http
|
let res = http
|
||||||
|
@ -90,5 +98,40 @@ pub async fn handle_outbox_request(
|
||||||
|
|
||||||
info!("status res {}", res);
|
info!("status res {}", res);
|
||||||
}
|
}
|
||||||
|
OutboxRequest::Follow { follower, followed, mut conn } => {
|
||||||
|
let follow = db::Follow {
|
||||||
|
id: ObjectUri(format!(
|
||||||
|
"https://ferri.amy.mov/activities/{}",
|
||||||
|
crate::new_id())
|
||||||
|
),
|
||||||
|
follower: follower.actor.id.clone(),
|
||||||
|
followed: followed.actor.id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
make::new_follow(follow, &mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let http = HttpWrapper::new(http, &follower.key_id);
|
||||||
|
|
||||||
|
let activity = PreparedActivity {
|
||||||
|
context: as_context(),
|
||||||
|
id: format!(
|
||||||
|
"https://ferri.amy.mov/activities/{}",
|
||||||
|
crate::new_id()
|
||||||
|
),
|
||||||
|
ty: ActivityType::Follow,
|
||||||
|
actor: follower.actor.id.0,
|
||||||
|
object: followed.actor.id.0,
|
||||||
|
published: crate::now_str(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = http
|
||||||
|
.post_activity(&followed.actor.inbox, activity)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("follow res {}", res);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{info, span, Instrument, Level};
|
use tracing::{info, span, Instrument, Level};
|
||||||
|
|
||||||
use crate::ap::http::HttpClient;
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::federation::http::HttpClient;
|
||||||
use crate::federation::inbox::handle_inbox_request;
|
use crate::federation::inbox::handle_inbox_request;
|
||||||
use crate::federation::outbox::handle_outbox_request;
|
use crate::federation::outbox::handle_outbox_request;
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
pub mod ap;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod federation;
|
pub mod federation;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use rand::{Rng, distributions::Alphanumeric};
|
use rand::{Rng, distributions::Alphanumeric};
|
||||||
|
|
||||||
pub fn gen_token(len: usize) -> String {
|
pub fn gen_token(len: usize) -> String {
|
||||||
|
@ -16,3 +16,11 @@ pub fn gen_token(len: usize) -> String {
|
||||||
pub fn new_id() -> String {
|
pub fn new_id() -> String {
|
||||||
uuid::Uuid::new_v4().to_string()
|
uuid::Uuid::new_v4().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn now() -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_str() -> String {
|
||||||
|
now().to_rfc3339()
|
||||||
|
}
|
||||||
|
|
223
ferri-main/src/types/ap.rs
Normal file
223
ferri-main/src/types/ap.rs
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
use super::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ActivityType {
|
||||||
|
Reject,
|
||||||
|
Create,
|
||||||
|
Note,
|
||||||
|
Delete,
|
||||||
|
Undo,
|
||||||
|
Accept,
|
||||||
|
Announce,
|
||||||
|
Person,
|
||||||
|
Like,
|
||||||
|
Follow,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct MinimalActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DeleteActivity = BasicActivity;
|
||||||
|
pub type LikeActivity = BasicActivity;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct BasicActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
pub object: String,
|
||||||
|
pub actor: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct CreateActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
pub object: Post,
|
||||||
|
pub actor: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "published")]
|
||||||
|
pub ts: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct FollowActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
pub object: String,
|
||||||
|
pub actor: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct AcceptActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
pub object: String,
|
||||||
|
pub actor: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct BoostActivity {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
pub actor: String,
|
||||||
|
pub published: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
pub object: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum PostAttachmentType {
|
||||||
|
Document
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PostAttachment {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: PostAttachmentType,
|
||||||
|
|
||||||
|
pub media_type: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_null_default")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
pub summary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sensitive: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Post {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
#[serde(rename = "published")]
|
||||||
|
pub ts: String,
|
||||||
|
pub content: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
|
||||||
|
pub attachment: Vec<PostAttachment>,
|
||||||
|
|
||||||
|
#[serde(rename = "attributedTo")]
|
||||||
|
pub attributed_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Actor {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
pub inbox: String,
|
||||||
|
pub outbox: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub enum IconType {
|
||||||
|
Image
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PersonIcon {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: IconType,
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub width: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub height: i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Person {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Object,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: ActivityType,
|
||||||
|
|
||||||
|
pub following: String,
|
||||||
|
pub followers: String,
|
||||||
|
|
||||||
|
pub summary: String,
|
||||||
|
pub inbox: String,
|
||||||
|
pub outbox: String,
|
||||||
|
|
||||||
|
pub preferred_username: String,
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
pub public_key: Option<UserKey>,
|
||||||
|
|
||||||
|
pub icon: Option<PersonIcon>
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RemoteInfo {
|
||||||
|
pub is_remote: bool,
|
||||||
|
pub web_url: String,
|
||||||
|
pub acct: String
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Person {
|
||||||
|
pub fn remote_info(&self) -> RemoteInfo {
|
||||||
|
let url = Url::parse(&self.obj.id.0).unwrap();
|
||||||
|
let host = url.host_str().unwrap();
|
||||||
|
|
||||||
|
let (acct, remote) = if host != "ferri.amy.mov" {
|
||||||
|
(format!("{}@{}", self.preferred_username, host), true)
|
||||||
|
} else {
|
||||||
|
(self.preferred_username.clone(), false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("https://ferri.amy.mov/{}", acct);
|
||||||
|
|
||||||
|
RemoteInfo {
|
||||||
|
acct: acct.to_string(),
|
||||||
|
web_url: url,
|
||||||
|
is_remote: remote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct UserKey {
|
||||||
|
pub id: String,
|
||||||
|
pub owner: String,
|
||||||
|
|
||||||
|
#[serde(rename = "publicKeyPem")]
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
249
ferri-main/src/types/api.rs
Normal file
249
ferri-main/src/types/api.rs
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
use super::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// API will not really use actors so treat them as DB actors
|
||||||
|
// until we require specificity
|
||||||
|
pub type Actor = db::Actor;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct CredentialApplication {
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: String,
|
||||||
|
pub redirect_uris: Vec<String>,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct WebfingerLink {
|
||||||
|
pub rel: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: Option<String>,
|
||||||
|
pub href: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
pub struct WebfingerHit {
|
||||||
|
pub subject: String,
|
||||||
|
pub aliases: Vec<String>,
|
||||||
|
pub links: Vec<WebfingerLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StatusAttachment {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub ty: String,
|
||||||
|
|
||||||
|
pub url: String,
|
||||||
|
pub description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Status {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
pub created_at: String,
|
||||||
|
pub in_reply_to_id: Option<ObjectUri>,
|
||||||
|
pub in_reply_to_account_id: Option<ObjectUri>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub spoiler_text: String,
|
||||||
|
pub visibility: String,
|
||||||
|
pub language: String,
|
||||||
|
pub uri: ObjectUri,
|
||||||
|
pub url: String,
|
||||||
|
pub replies_count: i64,
|
||||||
|
pub reblogs_count: i64,
|
||||||
|
pub favourites_count: i64,
|
||||||
|
pub favourited: bool,
|
||||||
|
pub reblogged: bool,
|
||||||
|
pub muted: bool,
|
||||||
|
pub bookmarked: bool,
|
||||||
|
pub content: String,
|
||||||
|
pub reblog: Option<Box<Status>>,
|
||||||
|
pub application: Option<()>,
|
||||||
|
pub account: Account,
|
||||||
|
pub media_attachments: Vec<StatusAttachment>,
|
||||||
|
pub mentions: Vec<Option<()>>,
|
||||||
|
pub tags: Vec<Option<()>>,
|
||||||
|
pub emojis: Vec<Option<()>>,
|
||||||
|
pub card: Option<()>,
|
||||||
|
pub poll: Option<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Relationship {
|
||||||
|
id: ObjectUuid,
|
||||||
|
following: bool,
|
||||||
|
showing_reblogs: bool,
|
||||||
|
notifying: bool,
|
||||||
|
followed_by: bool,
|
||||||
|
blocking: bool,
|
||||||
|
blocked_by: bool,
|
||||||
|
muting: bool,
|
||||||
|
muting_notifications: bool,
|
||||||
|
requested: bool,
|
||||||
|
requested_by: bool,
|
||||||
|
domain_blocking: bool,
|
||||||
|
endorsed: bool,
|
||||||
|
note: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
pub username: String,
|
||||||
|
pub acct: String,
|
||||||
|
pub display_name: String,
|
||||||
|
|
||||||
|
pub locked: bool,
|
||||||
|
pub bot: bool,
|
||||||
|
|
||||||
|
pub created_at: String,
|
||||||
|
pub attribution_domains: Vec<String>,
|
||||||
|
|
||||||
|
pub note: String,
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
pub avatar: String,
|
||||||
|
pub avatar_static: String,
|
||||||
|
pub header: String,
|
||||||
|
pub header_static: String,
|
||||||
|
|
||||||
|
pub followers_count: i64,
|
||||||
|
pub following_count: i64,
|
||||||
|
pub statuses_count: i64,
|
||||||
|
pub last_status_at: Option<String>,
|
||||||
|
|
||||||
|
pub emojis: Vec<Emoji>,
|
||||||
|
pub fields: Vec<CustomField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Emoji {
|
||||||
|
pub shortcode: String,
|
||||||
|
pub url: String,
|
||||||
|
pub static_url: String,
|
||||||
|
pub visible_in_picker: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CustomField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub verified_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Instance {
|
||||||
|
pub domain: String,
|
||||||
|
pub title: String,
|
||||||
|
pub version: String,
|
||||||
|
pub source_url: String,
|
||||||
|
pub description: String,
|
||||||
|
pub thumbnail: Thumbnail,
|
||||||
|
pub icon: Vec<Icon>,
|
||||||
|
pub languages: Vec<String>,
|
||||||
|
pub configuration: Configuration,
|
||||||
|
pub registrations: Registrations,
|
||||||
|
pub contact: Contact,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Configuration {
|
||||||
|
pub urls: Urls,
|
||||||
|
pub accounts: Accounts,
|
||||||
|
pub statuses: Statuses,
|
||||||
|
pub media_attachments: MediaAttachments,
|
||||||
|
pub polls: Polls,
|
||||||
|
pub translation: Translation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Accounts {
|
||||||
|
pub max_featured_tags: i64,
|
||||||
|
pub max_pinned_statuses: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MediaAttachments {
|
||||||
|
pub supported_mime_types: Vec<String>,
|
||||||
|
pub description_limit: i64,
|
||||||
|
pub image_size_limit: i64,
|
||||||
|
pub image_matrix_limit: i64,
|
||||||
|
pub video_size_limit: i64,
|
||||||
|
pub video_frame_rate_limit: i64,
|
||||||
|
pub video_matrix_limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Polls {
|
||||||
|
pub max_options: i64,
|
||||||
|
pub max_characters_per_option: i64,
|
||||||
|
pub min_expiration: i64,
|
||||||
|
pub max_expiration: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Statuses {
|
||||||
|
pub max_characters: i64,
|
||||||
|
pub max_media_attachments: i64,
|
||||||
|
pub characters_reserved_per_url: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Translation {
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Urls {
|
||||||
|
pub streaming: String,
|
||||||
|
pub about: String,
|
||||||
|
pub privacy_policy: String,
|
||||||
|
pub terms_of_service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Field {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Icon {
|
||||||
|
pub src: String,
|
||||||
|
pub size: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Registrations {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub approval_required: bool,
|
||||||
|
pub reason_required: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub min_age: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Thumbnail {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Preferences {
|
||||||
|
#[serde(rename = "posting:default:visibility")]
|
||||||
|
pub posting_default_visibility: String,
|
||||||
|
#[serde(rename = "posting:default:sensitive")]
|
||||||
|
pub posting_default_sensitive: bool,
|
||||||
|
#[serde(rename = "posting:default:language")]
|
||||||
|
pub posting_default_language: Option<String>,
|
||||||
|
#[serde(rename = "reading:expand:media")]
|
||||||
|
pub reading_expand_media: String,
|
||||||
|
#[serde(rename = "reading:expand:spoilers")]
|
||||||
|
pub reading_expand_spoilers: bool,
|
||||||
|
}
|
60
ferri-main/src/types/db.rs
Normal file
60
ferri-main/src/types/db.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
use super::*;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct Follow {
|
||||||
|
pub id: ObjectUri,
|
||||||
|
pub follower: ObjectUri,
|
||||||
|
pub followed: ObjectUri,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct Actor {
|
||||||
|
pub id: ObjectUri,
|
||||||
|
pub inbox: String,
|
||||||
|
pub outbox: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct UserPosts {
|
||||||
|
// User may have no posts
|
||||||
|
pub last_post_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
pub actor: Actor,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub acct: String,
|
||||||
|
pub remote: bool,
|
||||||
|
pub url: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub icon_url: String,
|
||||||
|
|
||||||
|
pub posts: UserPosts,
|
||||||
|
pub key_id: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
pub post_id: ObjectUuid,
|
||||||
|
pub url: String,
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub alt: Option<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
|
pub struct Post {
|
||||||
|
pub id: ObjectUuid,
|
||||||
|
pub uri: ObjectUri,
|
||||||
|
pub user: User,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub boosted_post: Option<Box<Post>>,
|
||||||
|
pub attachments: Vec<Attachment>
|
||||||
|
}
|
||||||
|
|
|
@ -25,7 +25,10 @@ fn parse_ts(ts: String) -> Option<DateTime<Utc>> {
|
||||||
Some(dt.unwrap())
|
Some(dt.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result<db::User, DbError> {
|
pub async fn user_by_id(
|
||||||
|
id: ObjectUuid,
|
||||||
|
conn: &mut SqliteConnection
|
||||||
|
) -> Result<db::User, DbError> {
|
||||||
info!("fetching user by uuid '{:?}' from the database", id);
|
info!("fetching user by uuid '{:?}' from the database", id);
|
||||||
|
|
||||||
let record = sqlx::query!(
|
let record = sqlx::query!(
|
||||||
|
@ -89,7 +92,7 @@ pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result<d
|
||||||
info!("user {:?} last posted {:?}", id, last_post_at);
|
info!("user {:?} last posted {:?}", id, last_post_at);
|
||||||
|
|
||||||
Ok(db::User {
|
Ok(db::User {
|
||||||
id: ObjectUuid(record.user_id),
|
id: ObjectUuid(record.user_id.clone()),
|
||||||
actor: db::Actor {
|
actor: db::Actor {
|
||||||
id: ObjectUri(record.actor_id),
|
id: ObjectUri(record.actor_id),
|
||||||
inbox: record.inbox,
|
inbox: record.inbox,
|
||||||
|
@ -102,7 +105,99 @@ pub async fn user_by_id(id: ObjectUuid, conn: &mut SqliteConnection) -> Result<d
|
||||||
created_at: user_created,
|
created_at: user_created,
|
||||||
url: record.url,
|
url: record.url,
|
||||||
posts: db::UserPosts { last_post_at },
|
posts: db::UserPosts { last_post_at },
|
||||||
icon_url: record.icon_url
|
icon_url: record.icon_url,
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
record.user_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_by_username(
|
||||||
|
username: &str,
|
||||||
|
conn: &mut SqliteConnection
|
||||||
|
) -> Result<db::User, DbError> {
|
||||||
|
info!("fetching user by username '{}' from the database", username);
|
||||||
|
|
||||||
|
let record = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id as "user_id",
|
||||||
|
u.username,
|
||||||
|
u.actor_id,
|
||||||
|
u.display_name,
|
||||||
|
a.inbox,
|
||||||
|
a.outbox,
|
||||||
|
u.url,
|
||||||
|
u.acct,
|
||||||
|
u.remote,
|
||||||
|
u.created_at,
|
||||||
|
u.icon_url
|
||||||
|
FROM "user" u
|
||||||
|
INNER JOIN "actor" a ON u.actor_id = a.id
|
||||||
|
WHERE u.username = ?1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::FetchError(e.to_string()))?;
|
||||||
|
|
||||||
|
let follower_count = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(follower_id)
|
||||||
|
FROM "follow"
|
||||||
|
WHERE followed_id = ?1
|
||||||
|
"#,
|
||||||
|
record.actor_id
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::FetchError(e.to_string()))?;
|
||||||
|
|
||||||
|
let last_post_at = sqlx::query_scalar!(
|
||||||
|
r#"
|
||||||
|
SELECT datetime(p.created_at)
|
||||||
|
FROM post p
|
||||||
|
WHERE p.user_id = ?1
|
||||||
|
ORDER BY datetime(p.created_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
record.user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbError::FetchError(e.to_string()))?
|
||||||
|
.flatten()
|
||||||
|
.and_then(|ts| {
|
||||||
|
info!("parsing timestamp {}", ts);
|
||||||
|
parse_ts(ts)
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_created = parse_ts(record.created_at).expect("no db corruption");
|
||||||
|
|
||||||
|
info!("user {} has {} followers", record.user_id, follower_count);
|
||||||
|
info!("user {} last posted {:?}", record.user_id, last_post_at);
|
||||||
|
|
||||||
|
Ok(db::User {
|
||||||
|
id: ObjectUuid(record.user_id.clone()),
|
||||||
|
actor: db::Actor {
|
||||||
|
id: ObjectUri(record.actor_id),
|
||||||
|
inbox: record.inbox,
|
||||||
|
outbox: record.outbox,
|
||||||
|
},
|
||||||
|
acct: record.acct,
|
||||||
|
remote: record.remote,
|
||||||
|
username: record.username,
|
||||||
|
display_name: record.display_name,
|
||||||
|
created_at: user_created,
|
||||||
|
url: record.url,
|
||||||
|
posts: db::UserPosts { last_post_at },
|
||||||
|
icon_url: record.icon_url,
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
record.user_id
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,7 +265,7 @@ pub async fn user_by_actor_uri(uri: ObjectUri, conn: &mut SqliteConnection) -> R
|
||||||
info!("user {:?} last posted {:?}", record.user_id, last_post_at);
|
info!("user {:?} last posted {:?}", record.user_id, last_post_at);
|
||||||
|
|
||||||
Ok(db::User {
|
Ok(db::User {
|
||||||
id: ObjectUuid(record.user_id),
|
id: ObjectUuid(record.user_id.clone()),
|
||||||
actor: db::Actor {
|
actor: db::Actor {
|
||||||
id: ObjectUri(record.actor_id),
|
id: ObjectUri(record.actor_id),
|
||||||
inbox: record.inbox,
|
inbox: record.inbox,
|
||||||
|
@ -183,7 +278,11 @@ pub async fn user_by_actor_uri(uri: ObjectUri, conn: &mut SqliteConnection) -> R
|
||||||
created_at: user_created,
|
created_at: user_created,
|
||||||
url: record.url,
|
url: record.url,
|
||||||
posts: db::UserPosts { last_post_at },
|
posts: db::UserPosts { last_post_at },
|
||||||
icon_url: record.icon_url
|
icon_url: record.icon_url,
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
record.user_id
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,7 +347,7 @@ pub async fn posts_for_user_id(
|
||||||
id: ObjectUuid(record.post_id),
|
id: ObjectUuid(record.post_id),
|
||||||
uri: ObjectUri(record.post_uri),
|
uri: ObjectUri(record.post_uri),
|
||||||
user: db::User {
|
user: db::User {
|
||||||
id: ObjectUuid(record.user_id),
|
id: ObjectUuid(record.user_id.clone()),
|
||||||
actor: db::Actor {
|
actor: db::Actor {
|
||||||
id: ObjectUri(record.actor_id),
|
id: ObjectUri(record.actor_id),
|
||||||
inbox: record.inbox,
|
inbox: record.inbox,
|
||||||
|
@ -263,7 +362,11 @@ pub async fn posts_for_user_id(
|
||||||
icon_url: record.icon_url,
|
icon_url: record.icon_url,
|
||||||
posts: db::UserPosts {
|
posts: db::UserPosts {
|
||||||
last_post_at: None
|
last_post_at: None
|
||||||
}
|
},
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
record.user_id
|
||||||
|
)
|
||||||
},
|
},
|
||||||
attachments,
|
attachments,
|
||||||
content: record.content,
|
content: record.content,
|
||||||
|
@ -305,7 +408,7 @@ pub async fn home_timeline(
|
||||||
id: ObjectUuid(p.post_id),
|
id: ObjectUuid(p.post_id),
|
||||||
uri: ObjectUri(p.post_uri),
|
uri: ObjectUri(p.post_uri),
|
||||||
user: db::User {
|
user: db::User {
|
||||||
id: ObjectUuid(p.user_id),
|
id: ObjectUuid(p.user_id.clone()),
|
||||||
actor: db::Actor {
|
actor: db::Actor {
|
||||||
id: ObjectUri(p.actor_id),
|
id: ObjectUri(p.actor_id),
|
||||||
inbox: p.inbox,
|
inbox: p.inbox,
|
||||||
|
@ -320,7 +423,11 @@ pub async fn home_timeline(
|
||||||
icon_url: p.icon_url,
|
icon_url: p.icon_url,
|
||||||
posts: db::UserPosts {
|
posts: db::UserPosts {
|
||||||
last_post_at: None
|
last_post_at: None
|
||||||
}
|
},
|
||||||
|
key_id: format!(
|
||||||
|
"https://ferri.amy.mov/users/{}#main-key",
|
||||||
|
p.user_id
|
||||||
|
)
|
||||||
},
|
},
|
||||||
content: p.content,
|
content: p.content,
|
||||||
created_at: parse_ts(p.post_created).unwrap(),
|
created_at: parse_ts(p.post_created).unwrap(),
|
||||||
|
@ -391,3 +498,53 @@ pub async fn home_timeline(
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn followers_for_user(
|
||||||
|
user_id: ObjectUuid,
|
||||||
|
conn: &mut SqliteConnection
|
||||||
|
) -> Result<Vec<db::Follow>, DbError> {
|
||||||
|
let followers = sqlx::query!(
|
||||||
|
"SELECT * FROM follow WHERE followed_id = ?",
|
||||||
|
user_id.0
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let followers = followers.into_iter()
|
||||||
|
.map(|f| {
|
||||||
|
db::Follow {
|
||||||
|
id: ObjectUri(f.id),
|
||||||
|
follower: ObjectUri(f.follower_id),
|
||||||
|
followed: ObjectUri(f.followed_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(followers)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn following_for_user(
|
||||||
|
user_id: ObjectUuid,
|
||||||
|
conn: &mut SqliteConnection
|
||||||
|
) -> Result<Vec<db::Follow>, DbError> {
|
||||||
|
let followers = sqlx::query!(
|
||||||
|
"SELECT * FROM follow WHERE follower_id = ?",
|
||||||
|
user_id.0
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let followers = followers.into_iter()
|
||||||
|
.map(|f| {
|
||||||
|
db::Follow {
|
||||||
|
id: ObjectUri(f.id),
|
||||||
|
follower: ObjectUri(f.follower_id),
|
||||||
|
followed: ObjectUri(f.followed_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(followers)
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@ pub mod convert;
|
||||||
pub mod get;
|
pub mod get;
|
||||||
pub mod make;
|
pub mod make;
|
||||||
|
|
||||||
|
pub mod db;
|
||||||
|
pub mod ap;
|
||||||
|
pub mod api;
|
||||||
|
|
||||||
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||||
where
|
where
|
||||||
T: Default + Deserialize<'de>,
|
T: Default + Deserialize<'de>,
|
||||||
|
@ -67,540 +71,3 @@ pub struct Object {
|
||||||
pub context: ObjectContext,
|
pub context: ObjectContext,
|
||||||
pub id: ObjectUri,
|
pub id: ObjectUri,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod db {
|
|
||||||
use super::*;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Follow {
|
|
||||||
pub id: ObjectUri,
|
|
||||||
pub follower: ObjectUri,
|
|
||||||
pub followed: ObjectUri,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Actor {
|
|
||||||
pub id: ObjectUri,
|
|
||||||
pub inbox: String,
|
|
||||||
pub outbox: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct UserPosts {
|
|
||||||
// User may have no posts
|
|
||||||
pub last_post_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct User {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
pub actor: Actor,
|
|
||||||
pub username: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub acct: String,
|
|
||||||
pub remote: bool,
|
|
||||||
pub url: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub icon_url: String,
|
|
||||||
|
|
||||||
pub posts: UserPosts,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Attachment {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
pub post_id: ObjectUuid,
|
|
||||||
pub url: String,
|
|
||||||
pub media_type: Option<String>,
|
|
||||||
pub sensitive: bool,
|
|
||||||
pub alt: Option<String>
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Post {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
pub uri: ObjectUri,
|
|
||||||
pub user: User,
|
|
||||||
pub content: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub boosted_post: Option<Box<Post>>,
|
|
||||||
pub attachments: Vec<Attachment>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod ap {
|
|
||||||
use super::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ActivityType {
|
|
||||||
Reject,
|
|
||||||
Create,
|
|
||||||
Note,
|
|
||||||
Delete,
|
|
||||||
Undo,
|
|
||||||
Accept,
|
|
||||||
Announce,
|
|
||||||
Person,
|
|
||||||
Like,
|
|
||||||
Follow,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct MinimalActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type DeleteActivity = BasicActivity;
|
|
||||||
pub type LikeActivity = BasicActivity;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct BasicActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
pub object: String,
|
|
||||||
pub actor: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct CreateActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
pub object: Post,
|
|
||||||
pub actor: String,
|
|
||||||
pub to: Vec<String>,
|
|
||||||
pub cc: Vec<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "published")]
|
|
||||||
pub ts: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct FollowActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
pub object: String,
|
|
||||||
pub actor: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct AcceptActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
pub object: String,
|
|
||||||
pub actor: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct BoostActivity {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
pub actor: String,
|
|
||||||
pub published: String,
|
|
||||||
pub to: Vec<String>,
|
|
||||||
pub cc: Vec<String>,
|
|
||||||
pub object: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub enum PostAttachmentType {
|
|
||||||
Document
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PostAttachment {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: PostAttachmentType,
|
|
||||||
|
|
||||||
pub media_type: String,
|
|
||||||
pub url: String,
|
|
||||||
#[serde(deserialize_with = "deserialize_null_default")]
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
pub summary: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub sensitive: bool
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct Post {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
#[serde(rename = "published")]
|
|
||||||
pub ts: String,
|
|
||||||
pub content: String,
|
|
||||||
pub to: Vec<String>,
|
|
||||||
pub cc: Vec<String>,
|
|
||||||
|
|
||||||
pub attachment: Vec<PostAttachment>,
|
|
||||||
|
|
||||||
#[serde(rename = "attributedTo")]
|
|
||||||
pub attributed_to: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Actor {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
pub inbox: String,
|
|
||||||
pub outbox: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub enum IconType {
|
|
||||||
Image
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct PersonIcon {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: IconType,
|
|
||||||
pub url: String,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub summary: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub width: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub height: i64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Person {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub obj: Object,
|
|
||||||
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: ActivityType,
|
|
||||||
|
|
||||||
pub following: String,
|
|
||||||
pub followers: String,
|
|
||||||
|
|
||||||
pub summary: String,
|
|
||||||
pub inbox: String,
|
|
||||||
pub outbox: String,
|
|
||||||
|
|
||||||
pub preferred_username: String,
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
pub public_key: Option<UserKey>,
|
|
||||||
|
|
||||||
pub icon: Option<PersonIcon>
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RemoteInfo {
|
|
||||||
pub is_remote: bool,
|
|
||||||
pub web_url: String,
|
|
||||||
pub acct: String
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Person {
|
|
||||||
pub fn remote_info(&self) -> RemoteInfo {
|
|
||||||
let url = Url::parse(&self.obj.id.0).unwrap();
|
|
||||||
let host = url.host_str().unwrap();
|
|
||||||
|
|
||||||
let (acct, remote) = if host != "ferri.amy.mov" {
|
|
||||||
(format!("{}@{}", self.preferred_username, host), true)
|
|
||||||
} else {
|
|
||||||
(self.preferred_username.clone(), false)
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("https://ferri.amy.mov/{}", acct);
|
|
||||||
|
|
||||||
RemoteInfo {
|
|
||||||
acct: acct.to_string(),
|
|
||||||
web_url: url,
|
|
||||||
is_remote: remote,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct UserKey {
|
|
||||||
pub id: String,
|
|
||||||
pub owner: String,
|
|
||||||
|
|
||||||
#[serde(rename = "publicKeyPem")]
|
|
||||||
pub public_key: String,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod api {
|
|
||||||
use super::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
// API will not really use actors so treat them as DB actors
|
|
||||||
// until we require specificity
|
|
||||||
pub type Actor = db::Actor;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct CredentialApplication {
|
|
||||||
pub name: String,
|
|
||||||
pub scopes: String,
|
|
||||||
pub redirect_uris: Vec<String>,
|
|
||||||
pub client_id: String,
|
|
||||||
pub client_secret: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
|
||||||
pub struct WebfingerLink {
|
|
||||||
pub rel: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: Option<String>,
|
|
||||||
pub href: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
|
||||||
pub struct WebfingerHit {
|
|
||||||
pub subject: String,
|
|
||||||
pub aliases: Vec<String>,
|
|
||||||
pub links: Vec<WebfingerLink>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct StatusAttachment {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub ty: String,
|
|
||||||
|
|
||||||
pub url: String,
|
|
||||||
pub description: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Status {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
pub created_at: String,
|
|
||||||
pub in_reply_to_id: Option<ObjectUri>,
|
|
||||||
pub in_reply_to_account_id: Option<ObjectUri>,
|
|
||||||
pub sensitive: bool,
|
|
||||||
pub spoiler_text: String,
|
|
||||||
pub visibility: String,
|
|
||||||
pub language: String,
|
|
||||||
pub uri: ObjectUri,
|
|
||||||
pub url: String,
|
|
||||||
pub replies_count: i64,
|
|
||||||
pub reblogs_count: i64,
|
|
||||||
pub favourites_count: i64,
|
|
||||||
pub favourited: bool,
|
|
||||||
pub reblogged: bool,
|
|
||||||
pub muted: bool,
|
|
||||||
pub bookmarked: bool,
|
|
||||||
pub content: String,
|
|
||||||
pub reblog: Option<Box<Status>>,
|
|
||||||
pub application: Option<()>,
|
|
||||||
pub account: Account,
|
|
||||||
pub media_attachments: Vec<StatusAttachment>,
|
|
||||||
pub mentions: Vec<Option<()>>,
|
|
||||||
pub tags: Vec<Option<()>>,
|
|
||||||
pub emojis: Vec<Option<()>>,
|
|
||||||
pub card: Option<()>,
|
|
||||||
pub poll: Option<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Account {
|
|
||||||
pub id: ObjectUuid,
|
|
||||||
pub username: String,
|
|
||||||
pub acct: String,
|
|
||||||
pub display_name: String,
|
|
||||||
|
|
||||||
pub locked: bool,
|
|
||||||
pub bot: bool,
|
|
||||||
|
|
||||||
pub created_at: String,
|
|
||||||
pub attribution_domains: Vec<String>,
|
|
||||||
|
|
||||||
pub note: String,
|
|
||||||
pub url: String,
|
|
||||||
|
|
||||||
pub avatar: String,
|
|
||||||
pub avatar_static: String,
|
|
||||||
pub header: String,
|
|
||||||
pub header_static: String,
|
|
||||||
|
|
||||||
pub followers_count: i64,
|
|
||||||
pub following_count: i64,
|
|
||||||
pub statuses_count: i64,
|
|
||||||
pub last_status_at: Option<String>,
|
|
||||||
|
|
||||||
pub emojis: Vec<Emoji>,
|
|
||||||
pub fields: Vec<CustomField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Emoji {
|
|
||||||
pub shortcode: String,
|
|
||||||
pub url: String,
|
|
||||||
pub static_url: String,
|
|
||||||
pub visible_in_picker: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
|
|
||||||
pub struct CustomField {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
pub verified_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Instance {
|
|
||||||
pub domain: String,
|
|
||||||
pub title: String,
|
|
||||||
pub version: String,
|
|
||||||
pub source_url: String,
|
|
||||||
pub description: String,
|
|
||||||
pub thumbnail: Thumbnail,
|
|
||||||
pub icon: Vec<Icon>,
|
|
||||||
pub languages: Vec<String>,
|
|
||||||
pub configuration: Configuration,
|
|
||||||
pub registrations: Registrations,
|
|
||||||
pub contact: Contact,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Configuration {
|
|
||||||
pub urls: Urls,
|
|
||||||
pub accounts: Accounts,
|
|
||||||
pub statuses: Statuses,
|
|
||||||
pub media_attachments: MediaAttachments,
|
|
||||||
pub polls: Polls,
|
|
||||||
pub translation: Translation,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Accounts {
|
|
||||||
pub max_featured_tags: i64,
|
|
||||||
pub max_pinned_statuses: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct MediaAttachments {
|
|
||||||
pub supported_mime_types: Vec<String>,
|
|
||||||
pub description_limit: i64,
|
|
||||||
pub image_size_limit: i64,
|
|
||||||
pub image_matrix_limit: i64,
|
|
||||||
pub video_size_limit: i64,
|
|
||||||
pub video_frame_rate_limit: i64,
|
|
||||||
pub video_matrix_limit: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Polls {
|
|
||||||
pub max_options: i64,
|
|
||||||
pub max_characters_per_option: i64,
|
|
||||||
pub min_expiration: i64,
|
|
||||||
pub max_expiration: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Statuses {
|
|
||||||
pub max_characters: i64,
|
|
||||||
pub max_media_attachments: i64,
|
|
||||||
pub characters_reserved_per_url: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Translation {
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Urls {
|
|
||||||
pub streaming: String,
|
|
||||||
pub about: String,
|
|
||||||
pub privacy_policy: String,
|
|
||||||
pub terms_of_service: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Contact {
|
|
||||||
pub email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Field {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Icon {
|
|
||||||
pub src: String,
|
|
||||||
pub size: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Registrations {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub approval_required: bool,
|
|
||||||
pub reason_required: bool,
|
|
||||||
pub message: Option<String>,
|
|
||||||
pub min_age: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Thumbnail {
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ap_actor_to_db() {
|
|
||||||
let domain = "https://example.com";
|
|
||||||
|
|
||||||
let ap = ap::Actor {
|
|
||||||
obj: Object {
|
|
||||||
context: as_context(),
|
|
||||||
id: ObjectUri(format!("{}/users/sample", domain)),
|
|
||||||
},
|
|
||||||
inbox: format!("{}/users/sample/inbox", domain),
|
|
||||||
outbox: format!("{}/users/sample/outbox", domain),
|
|
||||||
};
|
|
||||||
|
|
||||||
let db: db::Actor = ap.into();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
db,
|
|
||||||
db::Actor {
|
|
||||||
id: ObjectUri("https://example.com/users/sample".to_string()),
|
|
||||||
inbox: "https://example.com/users/sample/inbox".to_string(),
|
|
||||||
outbox: "https://example.com/users/sample/outbox".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,20 +1,14 @@
|
||||||
use rocket::{get, post, response::content::RawHtml};
|
use rocket::{get, response::content::RawHtml};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "index.html")]
|
#[template(path = "index.html")]
|
||||||
struct IndexTemplate {
|
struct IndexTemplate {
|
||||||
val: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/clicked")]
|
|
||||||
pub async fn button_clicked() -> RawHtml<String> {
|
|
||||||
let tmpl = IndexTemplate { val: "clicked".to_string() };
|
|
||||||
RawHtml(tmpl.render().unwrap())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub async fn index() -> RawHtml<String> {
|
pub async fn index() -> RawHtml<String> {
|
||||||
let tmpl = IndexTemplate { val: "test".to_string() };
|
let tmpl = IndexTemplate { };
|
||||||
RawHtml(tmpl.render().unwrap())
|
RawHtml(tmpl.render().unwrap())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,12 @@
|
||||||
|
use main::types::api;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
get,
|
get,
|
||||||
serde::{Deserialize, Serialize, json::Json},
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[serde(crate = "rocket::serde")]
|
|
||||||
pub struct Preferences {
|
|
||||||
#[serde(rename = "posting:default:visibility")]
|
|
||||||
pub posting_default_visibility: String,
|
|
||||||
#[serde(rename = "posting:default:sensitive")]
|
|
||||||
pub posting_default_sensitive: bool,
|
|
||||||
#[serde(rename = "posting:default:language")]
|
|
||||||
pub posting_default_language: Option<String>,
|
|
||||||
#[serde(rename = "reading:expand:media")]
|
|
||||||
pub reading_expand_media: String,
|
|
||||||
#[serde(rename = "reading:expand:spoilers")]
|
|
||||||
pub reading_expand_spoilers: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/preferences")]
|
#[get("/preferences")]
|
||||||
pub async fn preferences() -> Json<Preferences> {
|
pub async fn preferences() -> Json<api::Preferences> {
|
||||||
Json(Preferences {
|
Json(api::Preferences {
|
||||||
posting_default_visibility: "public".to_string(),
|
posting_default_visibility: "public".to_string(),
|
||||||
posting_default_sensitive: false,
|
posting_default_sensitive: false,
|
||||||
posting_default_language: None,
|
posting_default_language: None,
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
use rocket::{
|
use rocket::{
|
||||||
get, serde::json::Json, FromFormField, State,
|
get, serde::json::Json, FromFormField, State,
|
||||||
};
|
};
|
||||||
use main::types::{api, get};
|
use main::{federation::http::HttpWrapper, types::{api, get}};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{info, error};
|
use tracing::{info, error};
|
||||||
|
|
||||||
use crate::{http_wrapper::HttpWrapper, AuthenticatedUser, Db};
|
use crate::{AuthenticatedUser, Db};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, FromFormField, Debug)]
|
#[derive(Serialize, Deserialize, FromFormField, Debug)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
|
|
@ -43,7 +43,7 @@ fn to_db_post(req: &CreateStatus, user: &AuthenticatedUser, config: &Config) ->
|
||||||
uri: ObjectUri(config.post_url(&user.id.0, &post_id)),
|
uri: ObjectUri(config.post_url(&user.id.0, &post_id)),
|
||||||
user: user.user.clone(),
|
user: user.user.clone(),
|
||||||
content: req.status.clone(),
|
content: req.status.clone(),
|
||||||
created_at: main::ap::now(),
|
created_at: main::now(),
|
||||||
boosted_post: None,
|
boosted_post: None,
|
||||||
attachments: vec![]
|
attachments: vec![]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,48 +1,11 @@
|
||||||
use crate::{AuthenticatedUser, Db, endpoints::api::user::CredentialAcount};
|
use crate::{AuthenticatedUser, Db};
|
||||||
use main::types::{api, get, ObjectUuid};
|
use main::types::{api, get};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
get,
|
get,
|
||||||
serde::{Deserialize, Serialize, json::Json},
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
pub type TimelineAccount = CredentialAcount;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(crate = "rocket::serde")]
|
|
||||||
pub struct TimelineStatusAttachment {
|
|
||||||
id: ObjectUuid,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
ty: String,
|
|
||||||
url: String,
|
|
||||||
description: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(crate = "rocket::serde")]
|
|
||||||
pub struct TimelineStatus {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub in_reply_to_id: Option<String>,
|
|
||||||
pub in_reply_to_account_id: Option<String>,
|
|
||||||
pub content: String,
|
|
||||||
pub visibility: String,
|
|
||||||
pub spoiler_text: String,
|
|
||||||
pub sensitive: bool,
|
|
||||||
pub uri: String,
|
|
||||||
pub url: String,
|
|
||||||
pub replies_count: i64,
|
|
||||||
pub reblogs_count: i64,
|
|
||||||
pub favourites_count: i64,
|
|
||||||
pub favourited: bool,
|
|
||||||
pub reblogged: bool,
|
|
||||||
pub muted: bool,
|
|
||||||
pub bookmarked: bool,
|
|
||||||
pub reblog: Option<Box<TimelineStatus>>,
|
|
||||||
pub media_attachments: Vec<TimelineStatusAttachment>,
|
|
||||||
pub account: TimelineAccount,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/timelines/home")]
|
#[get("/timelines/home")]
|
||||||
pub async fn home(
|
pub async fn home(
|
||||||
mut db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
|
@ -50,7 +13,10 @@ pub async fn home(
|
||||||
) -> Json<Vec<api::Status>> {
|
) -> Json<Vec<api::Status>> {
|
||||||
let posts = get::home_timeline(user.actor_id, &mut **db)
|
let posts = get::home_timeline(user.actor_id, &mut **db)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.into())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(posts.into_iter().map(Into::into).collect())
|
Json(posts)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
use main::ap;
|
use main::federation::outbox::OutboxRequest;
|
||||||
|
use main::federation::QueueMessage;
|
||||||
|
use main::types::{api, get, ObjectUuid};
|
||||||
use rocket::response::status::NotFound;
|
use rocket::response::status::NotFound;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
State, get, post,
|
State, get, post,
|
||||||
serde::{Deserialize, Serialize, json::Json},
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
};
|
};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use uuid::Uuid;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::timeline::{TimelineAccount, TimelineStatus};
|
use crate::{AuthenticatedUser, Db, OutboundQueue};
|
||||||
use crate::{AuthenticatedUser, Db};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
|
@ -34,7 +35,8 @@ pub struct CredentialAcount {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/accounts/verify_credentials")]
|
#[get("/accounts/verify_credentials")]
|
||||||
pub async fn verify_credentials() -> Json<CredentialAcount> {
|
pub async fn verify_credentials(user: AuthenticatedUser) -> Json<CredentialAcount> {
|
||||||
|
info!("verifying creds for {:#?}", user);
|
||||||
Json(CredentialAcount {
|
Json(CredentialAcount {
|
||||||
id: "9b9d497b-2731-435f-a929-e609ca69dac9".to_string(),
|
id: "9b9d497b-2731-435f-a929-e609ca69dac9".to_string(),
|
||||||
username: "amy".to_string(),
|
username: "amy".to_string(),
|
||||||
|
@ -60,69 +62,49 @@ pub async fn verify_credentials() -> Json<CredentialAcount> {
|
||||||
#[post("/accounts/<uuid>/follow")]
|
#[post("/accounts/<uuid>/follow")]
|
||||||
pub async fn new_follow(
|
pub async fn new_follow(
|
||||||
mut db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
helpers: &State<crate::Helpers>,
|
outbound: &State<OutboundQueue>,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
) -> Result<(), NotFound<String>> {
|
) -> Result<(), NotFound<String>> {
|
||||||
let http = &helpers.http;
|
let follower = user.user;
|
||||||
|
let followed = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db)
|
||||||
let follower = ap::User::from_actor_id(&user.actor_id.0, &mut **db).await;
|
|
||||||
|
|
||||||
let followed = ap::User::from_id(uuid, &mut **db)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NotFound(e.to_string()))?;
|
.unwrap();
|
||||||
|
|
||||||
let outbox = ap::Outbox::for_user(follower.clone(), http);
|
let conn = db.into_inner();
|
||||||
|
let conn = conn.detach();
|
||||||
let activity = ap::Activity {
|
|
||||||
id: format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4()),
|
|
||||||
ty: ap::ActivityType::Follow,
|
|
||||||
object: followed.actor_id().to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let req = ap::OutgoingActivity {
|
|
||||||
signed_by: format!("{}#main-key", follower.uri()),
|
|
||||||
req: activity,
|
|
||||||
to: followed.actor().clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
req.save(&mut **db).await;
|
|
||||||
outbox.post(req).await;
|
|
||||||
|
|
||||||
|
let msg = QueueMessage::Outbound(OutboxRequest::Follow {
|
||||||
|
follower,
|
||||||
|
followed,
|
||||||
|
conn
|
||||||
|
});
|
||||||
|
|
||||||
|
outbound.0.send(msg).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/accounts/relationships?<id>")]
|
||||||
|
pub async fn relationships(
|
||||||
|
id: Vec<String>,
|
||||||
|
user: AuthenticatedUser
|
||||||
|
) -> Result<Json<Vec<api::Relationship>>, ()> {
|
||||||
|
info!("{} looking up relationships for {:#?}", user.username, id);
|
||||||
|
Ok(Json(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/accounts/<uuid>")]
|
#[get("/accounts/<uuid>")]
|
||||||
pub async fn account(
|
pub async fn account(
|
||||||
mut db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
_user: AuthenticatedUser,
|
_user: AuthenticatedUser,
|
||||||
) -> Result<Json<TimelineAccount>, NotFound<String>> {
|
) -> Result<Json<api::Account>, NotFound<String>> {
|
||||||
let user = ap::User::from_id(uuid, &mut **db)
|
let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NotFound(e.to_string()))?;
|
.map_err(|e| NotFound(e.to_string()))?;
|
||||||
let user_uri = format!("https://ferri.amy.mov/users/{}", user.username());
|
|
||||||
Ok(Json(CredentialAcount {
|
Ok(Json(user.into()))
|
||||||
id: user.id().to_string(),
|
|
||||||
username: user.username().to_string(),
|
|
||||||
acct: user.username().to_string(),
|
|
||||||
display_name: user.display_name().to_string(),
|
|
||||||
locked: false,
|
|
||||||
bot: false,
|
|
||||||
created_at: "2025-04-10T22:12:09Z".to_string(),
|
|
||||||
attribution_domains: vec![],
|
|
||||||
note: "".to_string(),
|
|
||||||
url: user_uri,
|
|
||||||
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
followers_count: 1,
|
|
||||||
following_count: 1,
|
|
||||||
statuses_count: 1,
|
|
||||||
last_status_at: "2025-04-10T22:14:34Z".to_string(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/accounts/<uuid>/statuses?<_limit>")]
|
#[get("/accounts/<uuid>/statuses?<_limit>")]
|
||||||
|
@ -131,69 +113,17 @@ pub async fn statuses(
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
_limit: Option<i64>,
|
_limit: Option<i64>,
|
||||||
_user: AuthenticatedUser,
|
_user: AuthenticatedUser,
|
||||||
) -> Result<Json<Vec<TimelineStatus>>, NotFound<String>> {
|
) -> Result<Json<Vec<api::Status>>, NotFound<String>> {
|
||||||
let user = ap::User::from_id(uuid, &mut **db)
|
let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NotFound(e.to_string()))?;
|
.map_err(|e| NotFound(e.to_string()))?;
|
||||||
|
|
||||||
let uid = user.id();
|
let posts = get::posts_for_user_id(user.id, &mut **db)
|
||||||
let posts = sqlx::query!(
|
.await
|
||||||
r#"
|
.unwrap()
|
||||||
SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", u.username, u.display_name, u.actor_id, p.created_at
|
.into_iter()
|
||||||
FROM post p
|
.map(|p| p.into())
|
||||||
INNER JOIN user u on p.user_id = u.id
|
.collect();
|
||||||
WHERE u.id = ?1
|
|
||||||
ORDER BY p.created_at DESC
|
Ok(Json(posts))
|
||||||
"#, uid)
|
|
||||||
.fetch_all(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut out = Vec::<TimelineStatus>::new();
|
|
||||||
for record in posts {
|
|
||||||
let user_uri = format!("https://ferri.amy.mov/users/{}", record.username);
|
|
||||||
out.push(TimelineStatus {
|
|
||||||
id: record.post_id.clone(),
|
|
||||||
created_at: record.created_at.clone(),
|
|
||||||
in_reply_to_id: None,
|
|
||||||
in_reply_to_account_id: None,
|
|
||||||
content: record.content.clone(),
|
|
||||||
visibility: "public".to_string(),
|
|
||||||
spoiler_text: "".to_string(),
|
|
||||||
sensitive: false,
|
|
||||||
uri: record.post_uri.clone(),
|
|
||||||
url: record.post_uri.clone(),
|
|
||||||
replies_count: 0,
|
|
||||||
reblogs_count: 0,
|
|
||||||
favourites_count: 0,
|
|
||||||
favourited: false,
|
|
||||||
reblogged: false,
|
|
||||||
reblog: None,
|
|
||||||
muted: false,
|
|
||||||
bookmarked: false,
|
|
||||||
media_attachments: vec![],
|
|
||||||
account: CredentialAcount {
|
|
||||||
id: record.user_id.clone(),
|
|
||||||
username: record.username.clone(),
|
|
||||||
acct: record.username.clone(),
|
|
||||||
display_name: record.display_name.clone(),
|
|
||||||
locked: false,
|
|
||||||
bot: false,
|
|
||||||
created_at: "2025-04-10T22:12:09Z".to_string(),
|
|
||||||
attribution_domains: vec![],
|
|
||||||
note: "".to_string(),
|
|
||||||
url: user_uri,
|
|
||||||
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
|
|
||||||
followers_count: 1,
|
|
||||||
following_count: 1,
|
|
||||||
statuses_count: 1,
|
|
||||||
last_status_at: "2025-04-10T22:14:34Z".to_string(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(out))
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,23 +50,14 @@ pub async fn followers(
|
||||||
mut db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
) -> Result<ActivityResponse<Json<OrderedCollection>>, NotFound<String>> {
|
) -> Result<ActivityResponse<Json<OrderedCollection>>, NotFound<String>> {
|
||||||
let target = main::ap::User::from_id(uuid, &mut **db)
|
let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NotFound(e.to_string()))?;
|
.unwrap();
|
||||||
|
|
||||||
let actor_id = target.actor_id();
|
let followers = get::followers_for_user(user.id.clone(), &mut **db)
|
||||||
|
.await
|
||||||
let followers = sqlx::query!(
|
.unwrap();
|
||||||
r#"
|
|
||||||
SELECT follower_id FROM follow
|
|
||||||
WHERE followed_id = ?
|
|
||||||
"#,
|
|
||||||
actor_id
|
|
||||||
)
|
|
||||||
.fetch_all(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
ap_ok(Json(OrderedCollection {
|
ap_ok(Json(OrderedCollection {
|
||||||
context: as_context(),
|
context: as_context(),
|
||||||
ty: "OrderedCollection".to_string(),
|
ty: "OrderedCollection".to_string(),
|
||||||
|
@ -74,8 +65,8 @@ pub async fn followers(
|
||||||
id: format!("https://ferri.amy.mov/users/{}/followers", uuid),
|
id: format!("https://ferri.amy.mov/users/{}/followers", uuid),
|
||||||
ordered_items: followers
|
ordered_items: followers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| f.follower_id)
|
.map(|f| f.follower.0)
|
||||||
.collect::<Vec<_>>(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,32 +75,23 @@ pub async fn following(
|
||||||
mut db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
) -> Result<ActivityResponse<Json<OrderedCollection>>, NotFound<String>> {
|
) -> Result<ActivityResponse<Json<OrderedCollection>>, NotFound<String>> {
|
||||||
let target = main::ap::User::from_id(uuid, &mut **db)
|
let user = get::user_by_id(ObjectUuid(uuid.to_string()), &mut **db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NotFound(e.to_string()))?;
|
.unwrap();
|
||||||
|
|
||||||
let actor_id = target.actor_id();
|
let followers = get::following_for_user(user.id.clone(), &mut **db)
|
||||||
|
.await
|
||||||
let following = sqlx::query!(
|
.unwrap();
|
||||||
r#"
|
|
||||||
SELECT followed_id FROM follow
|
|
||||||
WHERE follower_id = ?
|
|
||||||
"#,
|
|
||||||
actor_id
|
|
||||||
)
|
|
||||||
.fetch_all(&mut **db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
ap_ok(Json(OrderedCollection {
|
ap_ok(Json(OrderedCollection {
|
||||||
context: as_context(),
|
context: as_context(),
|
||||||
ty: "OrderedCollection".to_string(),
|
ty: "OrderedCollection".to_string(),
|
||||||
total_items: 1,
|
total_items: 1,
|
||||||
id: format!("https://ferri.amy.mov/users/{}/following", uuid),
|
id: format!("https://ferri.amy.mov/users/{}/following", uuid),
|
||||||
ordered_items: following
|
ordered_items: followers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| f.followed_id)
|
.map(|f| f.followed.0)
|
||||||
.collect::<Vec<_>>(),
|
.collect(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
use crate::Db;
|
use crate::Db;
|
||||||
use main::ap;
|
use main::types::{api, get};
|
||||||
use main::types::api;
|
use rocket::{get, serde::json::Json, State};
|
||||||
use rocket::{State, get, serde::json::Json};
|
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
@ -27,24 +26,26 @@ pub async fn webfinger(
|
||||||
|
|
||||||
let acct = resource.strip_prefix("acct:").unwrap();
|
let acct = resource.strip_prefix("acct:").unwrap();
|
||||||
let (user, _) = acct.split_once("@").unwrap();
|
let (user, _) = acct.split_once("@").unwrap();
|
||||||
let user = ap::User::from_username(user, &mut **db).await;
|
let user = get::user_by_username(user, &mut **db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
Json(api::WebfingerHit {
|
Json(api::WebfingerHit {
|
||||||
subject: resource.to_string(),
|
subject: resource.to_string(),
|
||||||
aliases: vec![
|
aliases: vec![
|
||||||
config.user_url(user.id()),
|
config.user_url(&user.id.0),
|
||||||
config.user_web_url(user.username()),
|
config.user_web_url(&user.username),
|
||||||
],
|
],
|
||||||
links: vec![
|
links: vec![
|
||||||
api::WebfingerLink {
|
api::WebfingerLink {
|
||||||
rel: "http://webfinger.net/rel/profile-page".to_string(),
|
rel: "http://webfinger.net/rel/profile-page".to_string(),
|
||||||
ty: Some("text/html".to_string()),
|
ty: Some("text/html".to_string()),
|
||||||
href: Some(config.user_web_url(user.username())),
|
href: Some(config.user_web_url(&user.username)),
|
||||||
},
|
},
|
||||||
api::WebfingerLink {
|
api::WebfingerLink {
|
||||||
rel: "self".to_string(),
|
rel: "self".to_string(),
|
||||||
ty: Some("application/activity+json".to_string()),
|
ty: Some("application/activity+json".to_string()),
|
||||||
href: Some(config.user_url(user.id())),
|
href: Some(config.user_url(&user.id.0)),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
use crate::http::HttpClient;
|
|
||||||
use main::types::ap;
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use thiserror::Error;
|
|
||||||
use tracing::{Level, error, event, info};
|
|
||||||
|
|
||||||
pub struct HttpWrapper<'a> {
|
|
||||||
client: &'a HttpClient,
|
|
||||||
key_id: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum HttpError {
|
|
||||||
#[error("entity of type `{0}` @ URL `{1}` could not be loaded")]
|
|
||||||
LoadFailure(String, String),
|
|
||||||
#[error("entity of type `{0}` @ URL `{1}` could not be parsed ({2})")]
|
|
||||||
ParseFailure(String, String, String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> HttpWrapper<'a> {
|
|
||||||
pub fn new(client: &'a HttpClient, key_id: &'a str) -> HttpWrapper<'a> {
|
|
||||||
Self { client, key_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client(&self) -> &'a HttpClient {
|
|
||||||
self.client
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get<T: serde::de::DeserializeOwned + Debug>(
|
|
||||||
&self,
|
|
||||||
ty: &str,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<T, HttpError> {
|
|
||||||
let ty = ty.to_string();
|
|
||||||
event!(Level::INFO, url, "loading {}", ty);
|
|
||||||
|
|
||||||
let http_result = self
|
|
||||||
.client
|
|
||||||
.get(url)
|
|
||||||
.sign(self.key_id)
|
|
||||||
.activity()
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = http_result {
|
|
||||||
error!("could not load url {}: {:#?}", url, e);
|
|
||||||
return Err(HttpError::LoadFailure(ty, url.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_body = http_result.unwrap().text().await;
|
|
||||||
if let Err(e) = raw_body {
|
|
||||||
error!("could not get text for url {}: {:#?}", url, e);
|
|
||||||
return Err(HttpError::LoadFailure(ty, url.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_body = raw_body.unwrap();
|
|
||||||
info!("raw body {}", raw_body);
|
|
||||||
let decoded = serde_json::from_str::<T>(&raw_body);
|
|
||||||
|
|
||||||
if let Err(e) = decoded {
|
|
||||||
error!(
|
|
||||||
"could not parse {} for url {}: {:#?} {}",
|
|
||||||
ty, url, e, &raw_body
|
|
||||||
);
|
|
||||||
return Err(HttpError::ParseFailure(ty, url.to_string(), e.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(decoded.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_person(&self, url: &str) -> Result<ap::Person, HttpError> {
|
|
||||||
self.get("Person", url).await
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,10 +4,8 @@ use endpoints::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use tracing_subscriber::fmt;
|
use tracing_subscriber::fmt;
|
||||||
|
use main::{federation::{self, http}, types::{db, get, ObjectUri, ObjectUuid}};
|
||||||
|
|
||||||
use main::{federation, types::{db, get, ObjectUri, ObjectUuid}};
|
|
||||||
|
|
||||||
use main::ap::http;
|
|
||||||
use main::config::Config;
|
use main::config::Config;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Build, Request, Rocket, build, get,
|
Build, Request, Rocket, build, get,
|
||||||
|
@ -134,7 +132,6 @@ pub fn launch(cfg: Config) -> Rocket<Build> {
|
||||||
"/admin",
|
"/admin",
|
||||||
routes![
|
routes![
|
||||||
admin::index,
|
admin::index,
|
||||||
admin::button_clicked
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
|
@ -171,6 +168,7 @@ pub fn launch(cfg: Config) -> Rocket<Build> {
|
||||||
api::user::new_follow,
|
api::user::new_follow,
|
||||||
api::user::statuses,
|
api::user::statuses,
|
||||||
api::user::account,
|
api::user::account,
|
||||||
|
api::user::relationships,
|
||||||
api::apps::new_app,
|
api::apps::new_app,
|
||||||
api::preferences::preferences,
|
api::preferences::preferences,
|
||||||
api::user::verify_credentials,
|
api::user::verify_credentials,
|
||||||
|
|
19
ferri-server/templates/_layout.html
Normal file
19
ferri-server/templates/_layout.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<base href="https://ferri.amy.mov/admin/">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>{% block title %}{% endblock %}</title>
|
||||||
|
|
||||||
|
{%~ block styles ~%} {% endblock ~%}
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss-preflight@1.0.1/preflight.min.css" rel="stylesheet" />
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{%~ block content %}{% endblock ~%}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,19 +1,45 @@
|
||||||
<!DOCTYPE html>
|
{% extends "_layout.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{%- block title -%}
|
||||||
<base href="https://ferri.amy.mov/admin/">
|
Control panel
|
||||||
|
{%- endblock -%}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{%- block styles -%}
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
<style>
|
||||||
<title>Ferri Test</title>
|
main {
|
||||||
|
display: grid;
|
||||||
<link rel="stylesheet" href="https://raw.githubusercontent.com/tailwindlabs/tailwindcss/dbc8023a08964f513c20796e170cb91ce891df3f/packages/tailwindcss/preflight.css">
|
grid-template-columns: repeat(2, 1fr);
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
grid-gap: 1rem;
|
||||||
</head>
|
padding: 1rem;
|
||||||
<body>
|
margin: auto;
|
||||||
<button hx-post="clicked" hx-swap="outerHTML">
|
max-width: 75%;
|
||||||
Click Me {{ val }}
|
margin: auto;
|
||||||
</button>
|
}
|
||||||
</body>
|
|
||||||
</html>
|
.grid-section {
|
||||||
|
border: 1px black solid;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<main>
|
||||||
|
<div class='grid-section'>
|
||||||
|
<h2>Test</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='grid-section'>
|
||||||
|
<h2>Test</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='grid-section'>
|
||||||
|
<h2>Test</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='grid-section'>
|
||||||
|
<h2>Test</h2>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{%- endblock -%}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue