580 lines
18 KiB
Rust
580 lines
18 KiB
Rust
use std::{
|
|
convert::{
|
|
TryFrom,
|
|
TryInto,
|
|
},
|
|
collections::BTreeSet,
|
|
fmt::Display,
|
|
str::FromStr,
|
|
};
|
|
use juniper::{
|
|
EmptySubscription,
|
|
ID,
|
|
RootNode,
|
|
graphql_object,
|
|
GraphQLObject,
|
|
GraphQLInputObject,
|
|
GraphQLEnum,
|
|
FieldResult,
|
|
FieldError,
|
|
};
|
|
use sqlx::{
|
|
types::chrono::{
|
|
DateTime,
|
|
Utc,
|
|
},
|
|
FromRow,
|
|
Encode,
|
|
Decode,
|
|
Type,
|
|
Row,
|
|
Result as SqlxResult,
|
|
Error as SqlxError,
|
|
};
|
|
#[cfg(feature = "sqlite")]
|
|
use sqlx::{
|
|
SqlitePool,
|
|
sqlite::SqliteRow,
|
|
};
|
|
use url::Url;
|
|
use uuid::Uuid;
|
|
|
|
#[inline]
|
|
fn into_limit<T>(
|
|
#[cfg(feature = "sqlite")]
|
|
row: &SqliteRow,
|
|
index: &str
|
|
) -> SqlxResult<Vec<T>>
|
|
where
|
|
T: FromStr,
|
|
<T as FromStr>::Err: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
{
|
|
Ok(from_sql_formatted_array(row.try_get(index)?, index)?.collect())
|
|
}
|
|
|
|
#[inline]
|
|
fn into_column_decode_err<E>(index: &str) -> impl FnOnce(E) -> SqlxError + '_
|
|
where
|
|
E: Into<Box<dyn std::error::Error + Send + Sync>>
|
|
{
|
|
move |err: E| SqlxError::ColumnDecode {
|
|
index: index.to_owned(),
|
|
source: err.into(),
|
|
}
|
|
}
|
|
|
|
fn from_sql_formatted_array<T>(raw: String, index: &str) -> SqlxResult<impl Iterator<Item = T>>
|
|
where
|
|
T: FromStr,
|
|
<T as FromStr>::Err: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
{
|
|
let mut raw_values: Vec<_> = raw.split(',').collect();
|
|
if raw_values[0] == raw {
|
|
raw_values.clear();
|
|
}
|
|
let mut values = Vec::new();
|
|
for r in raw_values.drain(..) {
|
|
values.push(r.parse().map_err(into_column_decode_err(index))?);
|
|
}
|
|
Ok(values.into_iter())
|
|
}
|
|
|
|
#[inline]
|
|
fn format_array_for_sql<I, T>(values: I) -> String
|
|
where
|
|
I: IntoIterator<Item=T>,
|
|
T: Display,
|
|
{
|
|
let mut iter = values.into_iter();
|
|
|
|
let mut output = match iter.next() {
|
|
Some(first) => first.to_string(),
|
|
None => return String::new(),
|
|
};
|
|
|
|
for v in iter {
|
|
output += ",";
|
|
output += &v.to_string();
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
#[inline]
|
|
fn into_toml_iter_from_row<'d, T>(
|
|
#[cfg(feature = "sqlite")]
|
|
row: &'d SqliteRow,
|
|
index: &str
|
|
) -> SqlxResult<impl Iterator<Item = T>>
|
|
where
|
|
T: FromStr,
|
|
<T as FromStr>::Err: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
{
|
|
from_sql_formatted_array(row.try_get(index)?, index)
|
|
}
|
|
|
|
#[inline]
|
|
fn id_to_uuid(id: &ID) -> Result<Uuid, uuid::Error> {
|
|
Uuid::from_str(&id)
|
|
}
|
|
|
|
fn is_valid_user_name(username: &str) -> bool {
|
|
if !username.starts_with('@') {
|
|
return false;
|
|
}
|
|
|
|
for c in username[1..].chars() {
|
|
if !(
|
|
c.is_alphabetic() && c.to_string() == c.to_lowercase().to_string() ||
|
|
c.is_ascii_digit() ||
|
|
"_-.".contains(c)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Context {
|
|
#[cfg(feature = "sqlite")]
|
|
pub db: SqlitePool,
|
|
}
|
|
|
|
impl juniper::Context for Context {}
|
|
|
|
#[derive(Clone, Debug, Decode, Type)]
|
|
pub struct User {
|
|
// can we change this to Uuid?
|
|
pub id: ID,
|
|
pub user_name: String,
|
|
pub display_name: Option<String>,
|
|
pub activated: bool,
|
|
pub created: DateTime<Utc>,
|
|
pub last_online: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[graphql_object(context = Context)]
|
|
impl User {
|
|
pub const fn id(&self) -> &ID {
|
|
&self.id
|
|
}
|
|
|
|
pub const fn user_name(&self) -> &String {
|
|
&self.user_name
|
|
}
|
|
|
|
pub const fn display_name(&self) -> Option<&String> {
|
|
self.display_name.as_ref()
|
|
}
|
|
|
|
pub const fn activated(&self) -> bool {
|
|
self.activated
|
|
}
|
|
|
|
pub const fn created(&self) -> &DateTime<Utc> {
|
|
&self.created
|
|
}
|
|
|
|
pub const fn last_online(&self) -> Option<&DateTime<Utc>> {
|
|
self.last_online.as_ref()
|
|
}
|
|
}
|
|
|
|
impl TryFrom<SqliteRow> for User {
|
|
type Error = FieldError;
|
|
|
|
fn try_from(row: SqliteRow) -> FieldResult<Self> {
|
|
let id = ID::new(row.try_get::<String, _>("id")?);
|
|
Ok(User {
|
|
id,
|
|
user_name: row.try_get("user_name")?,
|
|
display_name: row.try_get("display_name")?,
|
|
activated: row.try_get("activated")?,
|
|
created: row.try_get("created")?,
|
|
last_online: row.try_get("last_online")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, GraphQLInputObject)]
|
|
pub struct NewUser {
|
|
pub(crate) user_name: String,
|
|
pub(crate) display_name: Option<String>,
|
|
pub(crate) password_hash: String,
|
|
}
|
|
|
|
impl From<NewUser> for User {
|
|
fn from(new_user: NewUser) -> Self {
|
|
User {
|
|
// TODO: how do we generate uuids?
|
|
id: ID::new(Uuid::new_v4().to_string()),
|
|
user_name: new_user.user_name,
|
|
display_name: new_user.display_name,
|
|
activated: false,
|
|
created: Utc::now(),
|
|
last_online: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NewUser> for UserPreferences {
|
|
fn from(new_user: NewUser) -> Self {
|
|
UserPreferences {
|
|
privacy_preferences: Default::default(),
|
|
security_preferences: SecurityPreferences {
|
|
account_tokens: Vec::new(),
|
|
password_hash: new_user.password_hash,
|
|
},
|
|
notification_preferences: Default::default(),
|
|
external_servers_preferences: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, GraphQLObject, Decode, Encode, FromRow)]
|
|
pub struct UserPreferences {
|
|
privacy_preferences: PrivacyPreferences,
|
|
notification_preferences: NotificationPreferences,
|
|
security_preferences: SecurityPreferences,
|
|
external_servers_preferences: ExternalServersPreferences,
|
|
}
|
|
|
|
#[derive(Clone, Debug, GraphQLObject)]
|
|
pub struct PrivacyPreferences {
|
|
discovery: RestrictionPolicy,
|
|
discovery_user_limit: Vec<String>,
|
|
discovery_server_limit: Vec<Url>,
|
|
last_seen: RestrictionPolicy,
|
|
last_seen_user_limit: Vec<String>,
|
|
last_seen_server_limit: Vec<Url>,
|
|
last_seen_course: bool,
|
|
info: RestrictionPolicy,
|
|
info_user_limit: Vec<String>,
|
|
info_server_limit: Vec<Url>,
|
|
}
|
|
|
|
impl Default for PrivacyPreferences {
|
|
fn default() -> Self {
|
|
PrivacyPreferences {
|
|
discovery: Default::default(),
|
|
discovery_user_limit: Default::default(),
|
|
discovery_server_limit: Default::default(),
|
|
last_seen: Default::default(),
|
|
last_seen_user_limit: Default::default(),
|
|
last_seen_server_limit: Default::default(),
|
|
last_seen_course: true,
|
|
info: Default::default(),
|
|
info_user_limit: Default::default(),
|
|
info_server_limit: Default::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PrivacyPreferences {
|
|
fn from_row_cfg(
|
|
#[cfg(feature = "sqlite")]
|
|
row: &SqliteRow,
|
|
) -> SqlxResult<Self> {
|
|
Ok(PrivacyPreferences {
|
|
discovery: row.try_get("discovery")?,
|
|
discovery_user_limit: into_limit(row, "discovery_user_limit")?,
|
|
discovery_server_limit: into_limit(row, "discovery_server_limit")?,
|
|
last_seen: row.try_get("last_seen")?,
|
|
last_seen_user_limit: into_limit(row, "last_seen_user_limit")?,
|
|
last_seen_server_limit: into_limit(row, "last_seen_server_limit")?,
|
|
last_seen_course: row.try_get("last_seen_course")?,
|
|
info: row.try_get("info")?,
|
|
info_user_limit: into_limit(row, "info_user_limit")?,
|
|
info_server_limit: into_limit(row, "info_server_limit")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "sqlite")]
|
|
impl FromRow<'_, SqliteRow> for PrivacyPreferences {
|
|
fn from_row(row: &SqliteRow) -> SqlxResult<Self> {
|
|
Self::from_row_cfg(row)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, GraphQLObject, Type, FromRow)]
|
|
pub struct NotificationPreferences {
|
|
lock_details: bool,
|
|
do_not_disturb: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, GraphQLObject, Decode, Encode)]
|
|
pub struct SecurityPreferences {
|
|
account_tokens: Vec<ID>,
|
|
password_hash: String,
|
|
}
|
|
|
|
impl SecurityPreferences {
|
|
fn from_row_cfg(
|
|
#[cfg(feature = "sqlite")]
|
|
row: &SqliteRow,
|
|
) -> SqlxResult<Self> {
|
|
Ok(SecurityPreferences {
|
|
account_tokens: into_toml_iter_from_row::<String>(row, "account_tokens")?
|
|
.map(ID::new)
|
|
.collect(),
|
|
password_hash: row.try_get("password_hash")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "sqlite")]
|
|
impl FromRow<'_, SqliteRow> for SecurityPreferences {
|
|
fn from_row(row: &SqliteRow) -> SqlxResult<Self> {
|
|
Self::from_row_cfg(row)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, GraphQLObject, Decode, Encode, FromRow)]
|
|
pub struct ExternalServersPreferences {
|
|
privacy_preferences: PrivacyPreferences,
|
|
external_servers: RestrictionPolicy,
|
|
external_servers_limit: Vec<Url>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, GraphQLEnum, Type)]
|
|
#[repr(u8)]
|
|
pub enum RestrictionPolicy {
|
|
Everyone = 0,
|
|
Excluding = 1,
|
|
Including = 2,
|
|
None = 3,
|
|
}
|
|
|
|
impl Default for RestrictionPolicy {
|
|
fn default() -> Self {
|
|
Self::Everyone
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Query;
|
|
|
|
#[graphql_object(context = Context)]
|
|
impl Query {
|
|
async fn getUserID(context: &Context, username: String) -> FieldResult<ID> {
|
|
if !is_valid_user_name(&username) {
|
|
return Err(format!("{:?} is not a valid username", username).into());
|
|
}
|
|
|
|
sqlx::query(format!(
|
|
"SELECT id FROM users WHERE user_name={:?}",
|
|
username
|
|
).as_str()).fetch_one(&context.db).await?
|
|
.try_get::<String, _>("id")
|
|
.map(ID::new)
|
|
.map_err(FieldError::from)
|
|
}
|
|
|
|
async fn users(context: &Context) -> FieldResult<Vec<User>> {
|
|
let rows = sqlx::query("SELECT * FROM users")
|
|
.fetch_all(&context.db).await?;
|
|
|
|
let mut users = Vec::with_capacity(rows.capacity());
|
|
for row in rows {
|
|
users.push(row.try_into()?);
|
|
}
|
|
|
|
Ok(users)
|
|
}
|
|
|
|
async fn userPreferences(context: &Context, id: ID, password_hash: String) -> FieldResult<UserPreferences> {
|
|
let uuid = id_to_uuid(&id)?.to_simple();
|
|
|
|
let security_preferences: SecurityPreferences = sqlx::query_as(format!(
|
|
r#"SELECT * FROM security_preferences WHERE id = "{}""#,
|
|
uuid,
|
|
).as_str()).fetch_one(&context.db).await?;
|
|
|
|
if security_preferences.password_hash != password_hash {
|
|
return Err("incorrect password hash".into())
|
|
}
|
|
|
|
let privacy_preferences: PrivacyPreferences = sqlx::query_as(format!(
|
|
r#"SELECT * FROM privacy_preferences WHERE id = "{}""#,
|
|
uuid,
|
|
).as_str()).fetch_one(&context.db).await?;
|
|
|
|
let notification_preferences: NotificationPreferences = sqlx::query_as(format!(
|
|
r#"SELECT * FROM notification_preferences WHERE id = "{}""#,
|
|
uuid,
|
|
).as_str()).fetch_one(&context.db).await?;
|
|
|
|
let external_servers_preferences = sqlx::query(format!(
|
|
r#"SELECT * FROM external_servers_preferences WHERE id = "{}""#,
|
|
uuid,
|
|
).as_str()).fetch_one(&context.db).await?;
|
|
|
|
let external_servers_privacy_preferences: PrivacyPreferences = sqlx::query_as(format!(
|
|
r#"SELECT * FROM external_servers_privacy_preferences WHERE id = "{}""#,
|
|
uuid,
|
|
).as_str()).fetch_one(&context.db).await?;
|
|
|
|
Ok(UserPreferences {
|
|
privacy_preferences,
|
|
notification_preferences,
|
|
security_preferences,
|
|
external_servers_preferences: ExternalServersPreferences {
|
|
external_servers: external_servers_preferences.try_get("external_servers")?,
|
|
external_servers_limit: into_limit(&external_servers_preferences, "external_servers_limit")?,
|
|
privacy_preferences: external_servers_privacy_preferences,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Mutation;
|
|
|
|
#[graphql_object(context = Context)]
|
|
impl Mutation {
|
|
async fn createUser(context: &Context, new_user: NewUser) -> FieldResult<ID> {
|
|
let user: User = new_user.clone().into();
|
|
if !is_valid_user_name(&user.user_name) {
|
|
return Err(format!("{:?} is not a valid username", user.user_name).into());
|
|
}
|
|
|
|
let uuid = id_to_uuid(&user.id)?.to_simple();
|
|
let user_preferences: UserPreferences = new_user.into();
|
|
|
|
if sqlx::query(
|
|
format!("SELECT id FROM users WHERE user_name={:?}", user.user_name).as_str()
|
|
).fetch_all(&context.db).await?.len() > 0 {
|
|
return Err("username is already in use".into());
|
|
}
|
|
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO users VALUES ("{}", {:?}, {}, {}, {}, {})"#,
|
|
uuid,
|
|
user.user_name,
|
|
user.display_name.map(|x| format!("{:?}", x)).unwrap_or("NULL".to_string()),
|
|
user.activated as u8,
|
|
user.created.timestamp(),
|
|
match user.last_online {
|
|
None => "NULL".to_string(),
|
|
Some(d) => format!("{}", d.timestamp()),
|
|
},
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
let privacy_preferences = user_preferences.privacy_preferences;
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO privacy_preferences VALUES ("{}", {}, {:?}, {:?}, {}, {:?}, {:?}, {}, {}, {:?}, {:?})"#,
|
|
uuid,
|
|
privacy_preferences.discovery as u8,
|
|
format_array_for_sql(&privacy_preferences.discovery_user_limit),
|
|
format_array_for_sql(&privacy_preferences.discovery_server_limit),
|
|
privacy_preferences.last_seen as u8,
|
|
format_array_for_sql(&privacy_preferences.last_seen_user_limit),
|
|
format_array_for_sql(&privacy_preferences.last_seen_server_limit),
|
|
privacy_preferences.last_seen_course as u8,
|
|
privacy_preferences.info as u8,
|
|
format_array_for_sql(&privacy_preferences.info_user_limit),
|
|
format_array_for_sql(&privacy_preferences.info_server_limit),
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
let notification_preferences = user_preferences.notification_preferences;
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO notification_preferences VALUES ("{}", {}, {})"#,
|
|
uuid,
|
|
notification_preferences.lock_details as u8,
|
|
notification_preferences.do_not_disturb as u8,
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
let security_preferences = user_preferences.security_preferences;
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO security_preferences VALUES ("{}", {:?}, {:?})"#,
|
|
uuid,
|
|
format_array_for_sql(&security_preferences.account_tokens),
|
|
security_preferences.password_hash,
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
let external_servers_preferences = user_preferences.external_servers_preferences;
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO external_servers_preferences VALUES ("{}", {}, {:?})"#,
|
|
uuid,
|
|
external_servers_preferences.external_servers as u8,
|
|
format_array_for_sql(&external_servers_preferences.external_servers_limit),
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
let external_servers_privacy_preferences = external_servers_preferences.privacy_preferences;
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO external_servers_privacy_preferences VALUES ("{}", {}, {:?}, {:?}, {}, {:?}, {:?}, {}, {}, {:?}, {:?})"#,
|
|
uuid,
|
|
external_servers_privacy_preferences.discovery as u8,
|
|
format_array_for_sql(&external_servers_privacy_preferences.discovery_user_limit),
|
|
format_array_for_sql(&external_servers_privacy_preferences.discovery_server_limit),
|
|
external_servers_privacy_preferences.last_seen as u8,
|
|
format_array_for_sql(&external_servers_privacy_preferences.last_seen_user_limit),
|
|
format_array_for_sql(&external_servers_privacy_preferences.last_seen_server_limit),
|
|
external_servers_privacy_preferences.last_seen_course as u8,
|
|
external_servers_privacy_preferences.info as u8,
|
|
format_array_for_sql(&external_servers_privacy_preferences.info_user_limit),
|
|
format_array_for_sql(&external_servers_privacy_preferences.info_server_limit),
|
|
).as_str()).execute(&context.db).await?;
|
|
|
|
Ok(ID::new(id_to_uuid(&user.id)?.to_simple().to_string()))
|
|
}
|
|
|
|
async fn newChat(context: &Context, user: ID, password_hash: String, with: ID) -> FieldResult<ID> {
|
|
let user = id_to_uuid(&user)?.to_simple();
|
|
let chat_partner = id_to_uuid(&with)?.to_simple();
|
|
// Sort users
|
|
let users = BTreeSet::from_iter([user, chat_partner]);
|
|
|
|
// User authentication
|
|
let authentication_successful = sqlx::query(format!(
|
|
r#"SELECT users.id FROM users, security_preferences WHERE users.id = "{}" AND password_hash = "{}""#,
|
|
user,
|
|
password_hash,
|
|
).as_str()).fetch_optional(&context.db).await?.is_some();
|
|
if !authentication_successful {
|
|
return Err("authentication failed".into());
|
|
}
|
|
|
|
// Chat partner needs to be another user (for now)
|
|
if user == chat_partner {
|
|
return Err("chat partner is the same user".into());
|
|
}
|
|
|
|
// Chat partner must exist
|
|
let chat_partner_exists = sqlx::query(
|
|
format!(r#"SELECT id FROM users WHERE id = "{}""#, chat_partner).as_str()
|
|
).fetch_optional(&context.db).await?.is_some();
|
|
if !chat_partner_exists {
|
|
return Err(format!(r#"chat partner "{}" does not exist on this server"#, chat_partner).into());
|
|
}
|
|
|
|
// non-group chats must be unique
|
|
let chat_already_exists = sqlx::query(format!(
|
|
r#"SELECT id FROM chat_index WHERE is_group_chat = 0 AND users = "{}""#,
|
|
format_array_for_sql(&users),
|
|
).as_str()).fetch_optional(&context.db).await?.is_some();
|
|
if chat_already_exists {
|
|
return Err("a personal chat with this chat partner already exists".into());
|
|
}
|
|
|
|
// create new personal chat
|
|
let chat_uuid = Uuid::new_v4();
|
|
sqlx::query(format!(
|
|
r#"INSERT INTO chat_index VALUES ("{}", 0, "{}", NULL, "{1}")"#,
|
|
chat_uuid.to_simple(),
|
|
format_array_for_sql(users),
|
|
).as_str()).execute(&context.db).await?;
|
|
crate::sqlite::create_chat(&context.db, &chat_uuid).await?;
|
|
|
|
Ok(chat_uuid.to_simple().to_string().into())
|
|
}
|
|
}
|
|
|
|
pub type Schema<'root_node> = RootNode<'root_node, Query, Mutation, EmptySubscription<Context>>;
|
|
|
|
pub fn schema<'root_node>() -> Schema<'root_node> {
|
|
Schema::new(Query, Mutation, EmptySubscription::new())
|
|
} |