diff --git a/Cargo.lock b/Cargo.lock index 40e3711..30f3071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,6 +413,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.48.5", ] @@ -757,6 +758,7 @@ dependencies = [ "sqlx", "thiserror", "tracing", + "ulid", "uuid", ] diff --git a/chat/migrations/20240116203402_init.sql b/chat/migrations/20240116203402_init.sql index 2a76fed..f939089 100644 --- a/chat/migrations/20240116203402_init.sql +++ b/chat/migrations/20240116203402_init.sql @@ -31,6 +31,8 @@ create table guilds_users ( guild_id text not null references guilds (id) on delete cascade, user_id text not null references users (id) on delete cascade, + joined_at timestamptz not null default now(), + primary key (guild_id, user_id) ); diff --git a/chat/src/db/channel.rs b/chat/src/db/channel.rs new file mode 100644 index 0000000..ded4508 --- /dev/null +++ b/chat/src/db/channel.rs @@ -0,0 +1,47 @@ +use eyre::Result; +use foxchat::FoxError; +use sqlx::PgExecutor; +use ulid::Ulid; + +pub struct Channel { + pub id: String, + pub guild_id: String, + pub name: String, + pub topic: Option, +} + +pub async fn create_channel( + executor: impl PgExecutor<'_>, + guild_id: &str, + name: &str, + topic: Option, +) -> Result { + let channel = sqlx::query_as!( + Channel, + "insert into channels (id, guild_id, name, topic) values ($1, $2, $3, $4) returning *", + Ulid::new().to_string(), + guild_id, + name, + topic + ) + .fetch_one(executor) + .await?; + + Ok(channel) +} + +pub async fn get_channel(executor: impl PgExecutor<'_>, channel_id: &str) -> Result { + let channel = sqlx::query_as!(Channel, "select * from channels where id = $1", channel_id) + .fetch_one(executor) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => FoxError::NotInGuild, + _ => { + tracing::error!("database error: {}", e); + + return FoxError::DatabaseError; + } + })?; + + Ok(channel) +} diff --git a/chat/src/db/guild.rs b/chat/src/db/guild.rs new file mode 100644 index 0000000..ff56223 --- /dev/null +++ b/chat/src/db/guild.rs @@ -0,0 +1,71 @@ +use eyre::Result; +use foxchat::FoxError; +use sqlx::PgExecutor; +use ulid::Ulid; + +pub struct Guild { + pub id: String, + pub owner_id: String, + pub name: String, +} + +pub async fn create_guild( + executor: impl PgExecutor<'_>, + owner_id: &str, + name: &str, +) -> Result { + let guild = sqlx::query_as!( + Guild, + "insert into guilds (id, owner_id, name) values ($1, $2, $3) returning *", + Ulid::new().to_string(), + owner_id, + name + ) + .fetch_one(executor) + .await?; + + Ok(guild) +} + +pub async fn join_guild( + executor: impl PgExecutor<'_>, + guild_id: &str, + user_id: &str, +) -> Result<()> { + sqlx::query!( + "insert into guilds_users (guild_id, user_id) values ($1, $2) on conflict (guild_id, user_id) do nothing", + guild_id, + user_id + ) + .execute(executor) + .await?; + + Ok(()) +} + +pub async fn get_guild( + executor: impl PgExecutor<'_>, + guild_id: &str, + user_id: &str, +) -> Result { + println!("guild id: {}, user id: {}", guild_id, user_id); + + let guild = sqlx::query_as!( + Guild, + "select g.* from guilds_users u join guilds g on u.guild_id = g.id where u.guild_id = $1 and u.user_id = $2", + guild_id, + user_id + ) + .fetch_one(executor) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => FoxError::NotInGuild, + _ => { + tracing::error!("database error: {}", e); + + return FoxError::DatabaseError; + } + })?; + + Ok(guild) +} diff --git a/chat/src/db/message.rs b/chat/src/db/message.rs new file mode 100644 index 0000000..66b6aac --- /dev/null +++ b/chat/src/db/message.rs @@ -0,0 +1,33 @@ +use chrono::{DateTime, Utc}; +use eyre::Result; +use foxchat::model::http::channel::CreateMessageParams; +use sqlx::PgExecutor; +use ulid::Ulid; + +pub struct Message { + pub id: String, + pub channel_id: String, + pub author_id: String, + pub updated_at: DateTime, + pub content: String, +} + +pub async fn create_message( + executor: impl PgExecutor<'_>, + channel_id: &str, + user_id: &str, + params: CreateMessageParams, +) -> Result { + let message = sqlx::query_as!( + Message, + "insert into messages (id, channel_id, author_id, content) values ($1, $2, $3, $4) returning *", + Ulid::new().to_string(), + channel_id, + user_id, + params.content, + ) + .fetch_one(executor) + .await?; + + Ok(message) +} diff --git a/chat/src/db/mod.rs b/chat/src/db/mod.rs index bb07435..83b4fcf 100644 --- a/chat/src/db/mod.rs +++ b/chat/src/db/mod.rs @@ -1,3 +1,7 @@ +pub mod guild; +pub mod channel; +pub mod message; + use eyre::{OptionExt, Result}; use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey, LineEnding}; use rsa::{RsaPrivateKey, RsaPublicKey}; diff --git a/chat/src/http/api/channels/messages.rs b/chat/src/http/api/channels/messages.rs new file mode 100644 index 0000000..3574dc1 --- /dev/null +++ b/chat/src/http/api/channels/messages.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use axum::{extract::Path, Extension, Json}; +use eyre::Result; +use foxchat::{ + http::ApiError, + model::{http::channel::CreateMessageParams, Message, user::PartialUser}, + FoxError, ulid_timestamp, +}; + +use crate::{ + app_state::AppState, + db::{channel::get_channel, guild::get_guild, message::create_message}, + fed::FoxRequestData, + model::user::User, +}; + +pub async fn post_messages( + Extension(state): Extension>, + request: FoxRequestData, + Path(channel_id): Path, + Json(params): Json, +) -> Result, ApiError> { + let user_id = request.user_id.ok_or(FoxError::MissingUser)?; + let user = User::get(&state, &request.instance, &user_id).await?; + + let mut tx = state.pool.begin().await?; + let channel = get_channel(&mut *tx, &channel_id).await?; + let _guild = get_guild(&mut *tx, &channel.guild_id, &user.id).await?; + + let message = create_message(&mut *tx, &channel.id, &user.id, params).await?; + + tx.commit().await?; + + // TODO: dispatch message create event + + Ok(Json(Message { + id: message.id.clone(), + channel_id: channel.id, + author: PartialUser { + id: user.id, + username: user.username, + instance: request.instance.domain, + }, + content: Some(message.content), + created_at: ulid_timestamp(&message.id), + })) +} diff --git a/chat/src/http/api/channels/mod.rs b/chat/src/http/api/channels/mod.rs new file mode 100644 index 0000000..1cd23a3 --- /dev/null +++ b/chat/src/http/api/channels/mod.rs @@ -0,0 +1,7 @@ +mod messages; + +use axum::{routing::post, Router}; + +pub fn router() -> Router { + Router::new().route("/_fox/chat/channels/:id/messages", post(messages::post_messages)) +} diff --git a/chat/src/http/api/guilds/create_guild.rs b/chat/src/http/api/guilds/create_guild.rs index 0d094f2..1e0995c 100644 --- a/chat/src/http/api/guilds/create_guild.rs +++ b/chat/src/http/api/guilds/create_guild.rs @@ -3,30 +3,32 @@ use std::sync::Arc; use axum::{Extension, Json}; use foxchat::{ http::ApiError, - model::{http::guild::CreateGuildParams, Guild, user::PartialUser}, + model::{http::guild::CreateGuildParams, user::PartialUser, Guild, channel::PartialChannel}, FoxError, }; -use ulid::Ulid; -use crate::{app_state::AppState, fed::FoxRequestData, model::user::User}; +use crate::{ + app_state::AppState, + db::{channel::create_channel, guild::{create_guild, join_guild}}, + fed::FoxRequestData, + model::user::User, +}; -pub async fn create_guild( +pub async fn post_guilds( Extension(state): Extension>, request: FoxRequestData, Json(params): Json, ) -> Result, ApiError> { let user_id = request.user_id.ok_or(FoxError::MissingUser)?; + let user = User::get(&state, &request.instance, &user_id).await?; - let user = User::get(&state, &request.instance, user_id).await?; + let mut tx = state.pool.begin().await?; + let guild = create_guild(&mut *tx, &user.id, ¶ms.name).await?; + let channel = create_channel(&mut *tx, &guild.id, "general", None).await?; - let guild = sqlx::query!( - "insert into guilds (id, owner_id, name) values ($1, $2, $3) returning *", - Ulid::new().to_string(), - user.id, - params.name - ) - .fetch_one(&state.pool) - .await?; + join_guild(&mut *tx, &guild.id, &user.id).await?; + + tx.commit().await?; Ok(Json(Guild { id: guild.id, @@ -34,7 +36,11 @@ pub async fn create_guild( owner: PartialUser { id: user.id, username: user.username, - instance: request.instance.domain, + instance: user.instance.domain, + }, + default_channel: PartialChannel { + id: channel.id, + name: channel.name, } })) } diff --git a/chat/src/http/api/guilds/mod.rs b/chat/src/http/api/guilds/mod.rs index e625dc7..1006a5a 100644 --- a/chat/src/http/api/guilds/mod.rs +++ b/chat/src/http/api/guilds/mod.rs @@ -4,5 +4,5 @@ use axum::{Router, routing::post}; pub fn router() -> Router { Router::new() - .route("/_fox/chat/guilds", post(create_guild::create_guild)) + .route("/_fox/chat/guilds", post(create_guild::post_guilds)) } diff --git a/chat/src/http/api/mod.rs b/chat/src/http/api/mod.rs index 054cc8e..2715037 100644 --- a/chat/src/http/api/mod.rs +++ b/chat/src/http/api/mod.rs @@ -1,7 +1,10 @@ use axum::Router; +pub mod channels; pub mod guilds; pub fn router() -> Router { - Router::new().merge(guilds::router()) + Router::new() + .merge(guilds::router()) + .merge(channels::router()) } diff --git a/chat/src/model/identity_instance.rs b/chat/src/model/identity_instance.rs index c4f3feb..765ba0f 100644 --- a/chat/src/model/identity_instance.rs +++ b/chat/src/model/identity_instance.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::app_state::AppState; -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct IdentityInstance { pub id: String, pub domain: String, @@ -17,7 +17,7 @@ pub struct IdentityInstance { pub reason: Option, } -#[derive(Serialize, Deserialize, Debug, sqlx::Type)] +#[derive(Serialize, Deserialize, Clone, Debug, sqlx::Type)] #[sqlx(type_name = "instance_status", rename_all = "lowercase")] pub enum InstanceStatus { Active, diff --git a/chat/src/model/user.rs b/chat/src/model/user.rs index 1c81619..34566e0 100644 --- a/chat/src/model/user.rs +++ b/chat/src/model/user.rs @@ -11,6 +11,7 @@ use super::identity_instance::IdentityInstance; pub struct User { pub id: String, pub instance_id: String, + pub instance: IdentityInstance, pub remote_user_id: String, pub username: String, pub avatar: Option, @@ -20,10 +21,9 @@ impl User { pub async fn get( state: &Arc, instance: &IdentityInstance, - remote_id: String, + remote_id: &str, ) -> Result { - if let Some(user) = sqlx::query_as!( - User, + if let Some(user) = sqlx::query!( "select * from users where instance_id = $1 and remote_user_id = $2", instance.id, remote_id @@ -31,7 +31,14 @@ impl User { .fetch_optional(&state.pool) .await? { - return Ok(user); + return Ok(User { + id: user.id, + username: user.username, + instance_id: user.instance_id, + instance: instance.to_owned(), + remote_user_id: user.remote_user_id, + avatar: user.avatar, + }); } let http_user = fed::get::( @@ -43,8 +50,7 @@ impl User { ) .await?; - let user = sqlx::query_as!( - User, + let user = sqlx::query!( "insert into users (id, instance_id, remote_user_id, username, avatar) values ($1, $2, $3, $4, $5) returning *", Ulid::new().to_string(), instance.id, @@ -55,6 +61,13 @@ impl User { .fetch_one(&state.pool) .await?; - Ok(user) + Ok(User { + id: user.id, + username: user.username, + instance_id: user.instance_id, + instance: instance.to_owned(), + remote_user_id: user.remote_user_id, + avatar: user.avatar, + }) } } diff --git a/foxchat/Cargo.toml b/foxchat/Cargo.toml index ff4a8cb..17c7af4 100644 --- a/foxchat/Cargo.toml +++ b/foxchat/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" addr = "0.15.6" axum = "0.7.4" base64 = "0.21.7" -chrono = "0.4.31" +chrono = { version = "0.4.31", features = ["serde"] } eyre = "0.6.11" once_cell = "1.19.0" rand = "0.8.5" @@ -20,4 +20,5 @@ serde_json = "1.0.111" sqlx = "0.7.3" thiserror = "1.0.56" tracing = "0.1.40" +ulid = "1.1.0" uuid = { version = "1.6.1", features = ["v7"] } diff --git a/foxchat/src/error/mod.rs b/foxchat/src/error/mod.rs index 2f23a21..daaf9c6 100644 --- a/foxchat/src/error/mod.rs +++ b/foxchat/src/error/mod.rs @@ -7,6 +7,8 @@ pub enum FoxError { NotFound, #[error("date for signature out of range")] SignatureDateOutOfRange(&'static str), + #[error("database error")] + DatabaseError, #[error("non-200 response to federation request")] ResponseNotOk, #[error("server is invalid")] @@ -23,6 +25,10 @@ pub enum FoxError { Unauthorized, #[error("missing target user ID")] MissingUser, + #[error("user is not in guild or guild doesn't exist")] + NotInGuild, + #[error("channel not found")] + ChannelNotFound, } impl From for FoxError { diff --git a/foxchat/src/http/response.rs b/foxchat/src/http/response.rs index ad22a00..dd0ab2e 100644 --- a/foxchat/src/http/response.rs +++ b/foxchat/src/http/response.rs @@ -42,6 +42,7 @@ pub enum ErrorCode { InvalidDate, InvalidSignature, MissingSignature, + GuildNotFound, Unauthorized, } @@ -87,6 +88,11 @@ impl From for ApiError { code: ErrorCode::ObjectNotFound, message: "Object not found".into(), }, + FoxError::DatabaseError => ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: ErrorCode::InternalServerError, + message: "Database error".into(), + }, FoxError::SignatureDateOutOfRange(s) => ApiError { status: StatusCode::BAD_REQUEST, code: ErrorCode::InvalidSignature, @@ -127,10 +133,20 @@ impl From for ApiError { code: ErrorCode::InvalidHeader, message: "Missing user header".into(), }, + FoxError::NotInGuild => ApiError { + status: StatusCode::NOT_FOUND, + code: ErrorCode::GuildNotFound, + message: "Channel or guild not found".into(), + }, FoxError::Unauthorized => ApiError { status: StatusCode::UNAUTHORIZED, code: ErrorCode::Unauthorized, message: "Missing or invalid token".into(), + }, + FoxError::ChannelNotFound => ApiError { + status: StatusCode::NOT_FOUND, + code: ErrorCode::GuildNotFound, + message: "Channel or guild not found".into(), } } } diff --git a/foxchat/src/lib.rs b/foxchat/src/lib.rs index b697129..915cb89 100644 --- a/foxchat/src/lib.rs +++ b/foxchat/src/lib.rs @@ -6,3 +6,20 @@ pub mod s2s; pub use error::FoxError; pub use fed::signature; + +use chrono::{DateTime, Utc}; +use ulid::Ulid; + +/// Extracts a DateTime from a ULID. +/// This function should only be used on valid ULIDs (such as those used as primary keys), else it will fail and panic! +pub fn ulid_timestamp(id: &str) -> DateTime { + let ts = Ulid::from_string(id).expect("invalid ULID").timestamp_ms(); + let (secs, rem) = (ts / 1000, ts % 1000); + let nsecs = rem * 1000000; + + DateTime::from_timestamp( + secs.try_into().expect("converting secs to i64"), + nsecs.try_into().expect("converting nsecs to i32"), + ) + .expect("converting timestamp to DateTime") +} diff --git a/foxchat/src/model/channel.rs b/foxchat/src/model/channel.rs new file mode 100644 index 0000000..65be52a --- /dev/null +++ b/foxchat/src/model/channel.rs @@ -0,0 +1,15 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Channel { + pub id: String, + pub guild_id: String, + pub name: String, + pub topic: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PartialChannel { + pub id: String, + pub name: String, +} diff --git a/foxchat/src/model/guild.rs b/foxchat/src/model/guild.rs index 8f3a90f..11c0159 100644 --- a/foxchat/src/model/guild.rs +++ b/foxchat/src/model/guild.rs @@ -1,10 +1,11 @@ use serde::{Serialize, Deserialize}; -use super::user::PartialUser; +use super::{user::PartialUser, channel::PartialChannel}; #[derive(Serialize, Deserialize, Debug)] pub struct Guild { pub id: String, pub name: String, pub owner: PartialUser, + pub default_channel: PartialChannel, } diff --git a/foxchat/src/model/http/channel.rs b/foxchat/src/model/http/channel.rs new file mode 100644 index 0000000..c57bd5c --- /dev/null +++ b/foxchat/src/model/http/channel.rs @@ -0,0 +1,6 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateMessageParams { + pub content: String, +} diff --git a/foxchat/src/model/http/mod.rs b/foxchat/src/model/http/mod.rs index 4931e5f..032f16b 100644 --- a/foxchat/src/model/http/mod.rs +++ b/foxchat/src/model/http/mod.rs @@ -1 +1,2 @@ pub mod guild; +pub mod channel; diff --git a/foxchat/src/model/message.rs b/foxchat/src/model/message.rs new file mode 100644 index 0000000..05ec899 --- /dev/null +++ b/foxchat/src/model/message.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Serialize, Deserialize}; + +use super::user::PartialUser; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Message { + pub id: String, + pub channel_id: String, + pub author: PartialUser, + pub content: Option, + pub created_at: DateTime, +} diff --git a/foxchat/src/model/mod.rs b/foxchat/src/model/mod.rs index f44bc11..8d7933d 100644 --- a/foxchat/src/model/mod.rs +++ b/foxchat/src/model/mod.rs @@ -1,6 +1,10 @@ +pub mod channel; pub mod guild; -pub mod user; pub mod http; +pub mod message; +pub mod user; +pub use channel::Channel; pub use guild::Guild; +pub use message::Message; pub use user::User; diff --git a/foxchat/src/s2s/dispatch.rs b/foxchat/src/s2s/dispatch.rs new file mode 100644 index 0000000..0213be1 --- /dev/null +++ b/foxchat/src/s2s/dispatch.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "t", content = "d", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Dispatch { + MessageCreate +} diff --git a/foxchat/src/s2s/event.rs b/foxchat/src/s2s/event.rs index ca472d3..1ba5cba 100644 --- a/foxchat/src/s2s/event.rs +++ b/foxchat/src/s2s/event.rs @@ -1,11 +1,13 @@ use serde::{Deserialize, Serialize}; +use super::Dispatch; + #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "t", content = "d", rename_all = "SCREAMING_SNAKE_CASE")] pub enum Payload { Dispatch { #[serde(rename = "e")] - event: DispatchEvent, + event: Dispatch, #[serde(rename = "r")] recipients: Vec, }, @@ -14,7 +16,3 @@ pub enum Payload { token: String, }, } - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "t", content = "d", rename_all = "SCREAMING_SNAKE_CASE")] -pub enum DispatchEvent {} diff --git a/foxchat/src/s2s/mod.rs b/foxchat/src/s2s/mod.rs index a5a2d8e..2ded5ea 100644 --- a/foxchat/src/s2s/mod.rs +++ b/foxchat/src/s2s/mod.rs @@ -1,4 +1,6 @@ +mod dispatch; mod event; pub mod http; -pub use event::{DispatchEvent, Payload}; +pub use event::Payload; +pub use dispatch::Dispatch; diff --git a/identity/src/http/proxy/mod.rs b/identity/src/http/proxy/mod.rs index 601030c..3e7108b 100644 --- a/identity/src/http/proxy/mod.rs +++ b/identity/src/http/proxy/mod.rs @@ -5,7 +5,10 @@ use eyre::ContextCompat; use foxchat::{ fed, http::ApiError, - model::{http::guild::CreateGuildParams, Guild}, + model::{ + http::{channel::CreateMessageParams, guild::CreateGuildParams}, + Guild, Message, + }, }; use serde::{de::DeserializeOwned, Serialize}; use tracing::debug; @@ -15,7 +18,12 @@ use crate::{app_state::AppState, fed::ProxyServerHeader}; use super::auth::AuthUser; pub fn router() -> Router { - Router::new().route("/guilds", post(proxy_post::)) + Router::new() + .route("/guilds", post(proxy_post::)) + .route( + "/channels/:id/messages", + post(proxy_post::), + ) } async fn proxy_get(