add initial chat server stuff

This commit is contained in:
sam 2024-01-16 22:20:19 +01:00
parent 3025f1380c
commit 0e71e9dc5f
18 changed files with 722 additions and 40 deletions

View file

@ -9,9 +9,7 @@ edition = "2021"
foxchat = { path = "../foxchat" }
axum = { version = "0.7.4", features = ["macros", "query", "tracing", "ws"] }
clap = { version = "4.4.16", features = ["env", "derive"] }
dotenvy = "0.15.7"
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "uuid", "chrono", "json"] }
uuid = { version = "1.6.1", features = ["v7"] }
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "chrono", "json"] }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
ulid = { version = "1.1.0", features = ["serde"] }
@ -25,3 +23,6 @@ tracing-subscriber = "0.3.18"
tracing = "0.1.40"
tower-http = { version = "0.5.1", features = ["trace"] }
bcrypt = "0.15.0"
base64 = "0.21.7"
sha256 = "1.5.0"
reqwest = { version = "0.11.23", features = ["json", "gzip", "brotli", "multipart"] }

View file

@ -0,0 +1,4 @@
create table tokens (
token text primary key,
account_id text not null references accounts (id) on delete cascade
);

View file

@ -12,6 +12,9 @@ pub struct Config {
pub port: u16,
pub auto_migrate: Option<bool>,
pub log_level: Option<String>,
/// Whether to try HTTP if a server is not served over HTTPS.
/// This should only be set to `true` in development.
pub insecure_requests: Option<bool>,
}
impl Config {
@ -40,6 +43,11 @@ impl Config {
_ => None
}
}
/// If true, the server will try to identify remote servers over HTTP if they are not available over HTTPS
pub fn should_try_insecure(&self) -> bool {
self.insecure_requests.unwrap_or(false)
}
}
impl Default for Config {
@ -49,6 +57,7 @@ impl Default for Config {
port: 3000,
auto_migrate: None,
log_level: None,
insecure_requests: None,
}
}
}

View file

@ -0,0 +1,83 @@
use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
use eyre::{Context, Report, Result};
use foxchat::QueryError;
use rand::RngCore;
use sqlx::{PgExecutor, Pool, Postgres};
use crate::model::account::{Account, Role};
pub async fn get_user_by_username_and_password(
pool: Pool<Postgres>,
username: String,
password: String,
) -> Result<Account> {
let account = sqlx::query_as!(
Account,
r#"select
id, username, email, password, role as "role: Role", avatar
from accounts where username = $1"#,
username
)
.fetch_one(&pool)
.await
.map_err(|e| -> Report {
match e {
sqlx::Error::RowNotFound => QueryError::NotFound.into(),
_ => e.into(),
}
})?;
if bcrypt::verify(password, &account.password).map_err(|_| QueryError::NotFound)? {
return Ok(account);
}
Err(QueryError::NotFound.into())
}
pub async fn check_token(pool: &Pool<Postgres>, token: String) -> Result<Account> {
let hash = sha256::digest(token);
let account = sqlx::query_as!(
Account,
r#"select
a.id, a.username, a.email, a.password, a.role as "role: Role", a.avatar
from accounts a
join tokens t on t.account_id = a.id
where t.account_id = $1"#,
hash
)
.fetch_optional(pool)
.await?;
match account {
Some(a) => Ok(a),
None => Err(QueryError::NotFound.into()),
}
}
pub async fn create_token(executor: impl PgExecutor<'_>, user_id: &String) -> Result<String> {
let token = generate_token_string();
let hash = sha256::digest(&token);
sqlx::query!(
"insert into tokens (token, account_id) values ($1, $2)",
hash,
user_id
)
.execute(executor)
.await?;
Ok(token)
}
fn generate_token_string() -> String {
let mut data = [0u8; 32];
rand::thread_rng().fill_bytes(&mut data);
BASE64_URL_SAFE_NO_PAD.encode(data)
}
const PASSWORD_COST: u32 = 12;
pub fn hash_password(password: String) -> Result<String> {
bcrypt::hash(password, PASSWORD_COST).wrap_err("failed to hash password")
}

View file

@ -1,5 +1,9 @@
pub mod account;
pub use account::{check_token, create_token, get_user_by_username_and_password};
use eyre::{OptionExt, Result};
use rsa::pkcs1::{LineEnding, EncodeRsaPublicKey, EncodeRsaPrivateKey};
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey, LineEnding};
use rsa::{RsaPrivateKey, RsaPublicKey};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};

View file

@ -1,31 +1,34 @@
use std::sync::Arc;
use axum::{Extension, Json};
use eyre::{Context, Result};
use eyre::Result;
use foxchat::http::ApiError;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::app_state::AppState;
const PASSWORD_COST: u32 = 12;
use crate::{app_state::AppState, db::{create_token, account::hash_password}};
pub async fn create_user(
Extension(state): Extension<Arc<AppState>>,
Json(data): Json<CreateUserRequest>,
) -> Result<Json<CreateUserResponse>, ApiError> {
let password_hash =
bcrypt::hash(data.password, PASSWORD_COST).wrap_err("failed to hash password")?;
let mut tx = state.pool.begin().await?;
let password_hash = hash_password(data.password)?;
let account = sqlx::query!(
"insert into accounts (id, username, email, password) values ($1, $2, $3, $4) returning id, username",
Ulid::new().to_string(), data.username, data.email, password_hash)
.fetch_one(&state.pool)
.fetch_one(&mut *tx)
.await?;
let token = create_token(&mut *tx, &account.id).await?;
tx.commit().await?;
Ok(Json(CreateUserResponse {
id: account.id,
username: account.username,
token,
}))
}
@ -40,4 +43,5 @@ pub struct CreateUserRequest {
pub struct CreateUserResponse {
pub id: String,
pub username: String,
pub token: String,
}

View file

@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize};
use ulid::Ulid;
pub struct Account {
pub id: Ulid,
pub id: String,
pub username: String,
pub email: String,
pub password: String,

View file

@ -1,8 +1,12 @@
use std::sync::Arc;
use eyre::Result;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::app_state::AppState;
pub struct ChatInstance {
pub id: Ulid,
pub id: String,
pub domain: String,
pub base_url: String,
pub public_key: String,
@ -16,3 +20,25 @@ pub enum InstanceStatus {
Active,
Suspended,
}
impl ChatInstance {
pub async fn get(state: Arc<AppState>, domain: String) -> Result<Self> {
if let Some(instance) = sqlx::query_as!(
Self,
r#"select id, domain, base_url, public_key,
status as "status: InstanceStatus", reason
from chat_instances where domain = $1"#,
domain
)
.fetch_optional(&state.pool)
.await?
{
return Ok(instance);
}
// TODO: identify server process
// only try HTTP if `state.config.should_try_insecure()`
todo!()
}
}

View file

@ -9,6 +9,8 @@ pub struct Instance {
}
impl Instance {
/// Gets the instance's configuration.
/// This is a singleton row that is always present.
pub async fn get(pool: &Pool<Postgres>) -> Result<Self> {
let instance = sqlx::query!("SELECT * FROM instance WHERE id = 1")
.fetch_one(pool)