From e667b4452b2e862c772c8ddac4ea7ec857861b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sof=C3=ADa=20Aritz?= Date: Wed, 16 Oct 2024 20:55:29 +0200 Subject: [PATCH] Initial impl of entry endpoints and various improvements --- identity-api-rs/Cargo.toml | 2 +- identity-api-rs/src/auth.rs | 9 +- identity-api-rs/src/database/actions.rs | 110 +++++- identity-api-rs/src/database/list.rs | 22 +- identity-api-rs/src/database/mod.rs | 5 +- identity-api-rs/src/database/models.rs | 71 ++-- identity-api-rs/src/env.rs | 10 +- identity-api-rs/src/http/entry.rs | 220 +++++++++++ identity-api-rs/src/http/extractors/auth.rs | 37 +- .../src/http/extractors/database.rs | 30 ++ identity-api-rs/src/http/extractors/mod.rs | 3 +- identity-api-rs/src/http/mod.rs | 3 +- identity-api-rs/src/http/routes/auth.rs | 372 ++++++++++-------- identity-api-rs/src/http/routes/entry.rs | 265 +++++++++++++ identity-api-rs/src/http/routes/mod.rs | 3 +- identity-api-rs/src/main.rs | 46 ++- identity-api/src/database.ts | 2 +- identity-api/src/routes/entry/index.ts | 4 +- identity-web/src/lib/entry.ts | 4 +- .../src/routes/dashboard/Entries.svelte | 8 +- .../src/routes/entry/new/+page.svelte | 2 +- 21 files changed, 957 insertions(+), 271 deletions(-) create mode 100644 identity-api-rs/src/http/entry.rs create mode 100644 identity-api-rs/src/http/extractors/database.rs create mode 100644 identity-api-rs/src/http/routes/entry.rs diff --git a/identity-api-rs/Cargo.toml b/identity-api-rs/Cargo.toml index 3ba8ac9..01fd704 100644 --- a/identity-api-rs/Cargo.toml +++ b/identity-api-rs/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies] argon2 = "0.5.3" axum = { version = "0.7", features = ["macros", "tracing"] } -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["trace", "cors"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/identity-api-rs/src/auth.rs b/identity-api-rs/src/auth.rs index acaea49..d1fb2f6 100644 --- a/identity-api-rs/src/auth.rs +++ b/identity-api-rs/src/auth.rs @@ -17,8 +17,8 @@ use std::time::SystemTime; use crate::env; -use jsonwebtoken::{TokenData, Header, Validation}; -use serde::{Serialize, Deserialize}; +use jsonwebtoken::{Header, TokenData, Validation}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct JwtUser { @@ -40,5 +40,6 @@ pub fn expiration_time() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("time went backwards") - .as_secs() + 30 * 24 * 3600 -} \ No newline at end of file + .as_secs() + + 30 * 24 * 3600 +} diff --git a/identity-api-rs/src/database/actions.rs b/identity-api-rs/src/database/actions.rs index 99eaa7a..88de978 100644 --- a/identity-api-rs/src/database/actions.rs +++ b/identity-api-rs/src/database/actions.rs @@ -14,22 +14,23 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use diesel::{SqliteConnection, r2d2::{ConnectionManager, PooledConnection}, RunQueryDsl, QueryDsl, SelectableHelper, ExpressionMethods, OptionalExtension}; -use crate::database::models::User; +use diesel::{ + r2d2::{ConnectionManager, PooledConnection}, + result::QueryResult, + ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, + SqliteConnection, +}; -use super::models::Heir; +use super::models::{DateEntry, Entry, FullDatabaseEntry, Heir, LocationEntry, MusicEntry, User}; type Connection<'a> = &'a mut PooledConnection>; -pub fn user(user_id: &str, conn: Connection) -> diesel::result::QueryResult { +pub fn user(user_id: &str, conn: Connection) -> QueryResult { use crate::database::schema::users::dsl::users; - users - .find(user_id) - .select(User::as_select()) - .first(conn) + users.find(user_id).select(User::as_select()).first(conn) } -pub fn user_by_email(email: &str, conn: Connection) -> diesel::result::QueryResult> { +pub fn user_by_email(email: &str, conn: Connection) -> QueryResult> { use crate::database::schema::users::dsl as users; users::users .filter(users::email.eq(email)) @@ -39,10 +40,97 @@ pub fn user_by_email(email: &str, conn: Connection) -> diesel::result::QueryResu .optional() } -pub fn list_heirs(user_id: &str, conn: Connection) -> diesel::result::QueryResult> { +pub fn list_heirs(user_id: &str, conn: Connection) -> QueryResult> { use crate::database::schema::heirs::dsl as heirs; heirs::heirs .filter(heirs::user_id.eq(user_id)) .select(Heir::as_select()) .load(conn) -} \ No newline at end of file +} + +pub fn insert_music_entry(music_entry: &MusicEntry, conn: Connection) -> QueryResult<()> { + use crate::database::schema::music_entries::dsl::*; + diesel::insert_into(music_entries) + .values(( + id.eq(&music_entry.id), + artist.eq(&music_entry.artist), + title.eq(&music_entry.title), + links.eq(music_entry.links.to_string()), + universal_ids.eq(music_entry.universal_ids.to_string()), + )) + .execute(conn)?; + + Ok(()) +} + +pub fn insert_location_entry(location_entry: &LocationEntry, conn: Connection) -> QueryResult<()> { + use crate::database::schema::location_entries::dsl::*; + diesel::insert_into(location_entries) + .values(location_entry) + .execute(conn)?; + + Ok(()) +} + +pub fn insert_date_entry(date_entry: &DateEntry, conn: Connection) -> QueryResult<()> { + use crate::database::schema::date_entries::dsl::*; + diesel::insert_into(date_entries) + .values(date_entry) + .execute(conn)?; + + Ok(()) +} + +macro_rules! retrieve_sub_entry { + (($model:ident, $conn:ident) from $dsl:ident with id $id:expr) => {{ + use $crate::database::schema::$dsl::dsl::$dsl; + let value = $id + .as_ref() + .map(|id| $dsl.find(id).select($model::as_select()).first($conn)); + + match value { + Some(result) => Some(result?), + None => None, + } + }}; +} + +pub fn entry_recursive( + entry_id: &str, + conn: Connection, +) -> QueryResult { + use crate::database::schema::entries::dsl::entries; + + let entry: Entry = entries + .find(entry_id) + .select(Entry::as_select()) + .first(conn)?; + + let music_entry = + retrieve_sub_entry!((MusicEntry, conn) from music_entries with id entry.music_entry); + let location_entry = retrieve_sub_entry!((LocationEntry, conn) from location_entries with id entry.location_entry); + let date_entry = + retrieve_sub_entry!((DateEntry, conn) from date_entries with id entry.date_entry); + + Ok((entry, music_entry, location_entry, date_entry)) +} + +pub fn list_entries_recursive( + user_id: &str, + offset: i64, + limit: i64, + conn: Connection, +) -> QueryResult> { + use crate::database::schema::entries::dsl as entries; + + let entry_ids = entries::entries + .filter(entries::user_id.eq(user_id)) + .limit(limit) + .offset(offset) + .select(entries::id) + .load::(conn)?; + entry_ids + .iter() + .map(|id| entry_recursive(id, conn)) + .collect() +} diff --git a/identity-api-rs/src/database/list.rs b/identity-api-rs/src/database/list.rs index fc5623e..74ac982 100644 --- a/identity-api-rs/src/database/list.rs +++ b/identity-api-rs/src/database/list.rs @@ -14,11 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::fmt::Display; use diesel::{ - backend::Backend, deserialize::{FromSql, FromSqlRow}, serialize::ToSql, sql_types::Text, sqlite::Sqlite + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + serialize::ToSql, + sql_types::Text, + sqlite::Sqlite, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::fmt::Display; #[derive(FromSqlRow, Deserialize, Serialize, Debug, Clone)] #[serde(transparent)] @@ -35,7 +39,7 @@ impl List { } } -impl Display for List { +impl Display for List { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&serde_json::to_string(&self).unwrap()) } @@ -50,16 +54,20 @@ where } } -impl FromSql for List -{ +impl From> for List { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl FromSql for List { fn from_sql(bytes: ::RawValue<'_>) -> diesel::deserialize::Result { let str = >::from_sql(bytes)?; Ok(List::from(str)) } } -impl ToSql for List -{ +impl ToSql for List { fn to_sql<'b>( &'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>, diff --git a/identity-api-rs/src/database/mod.rs b/identity-api-rs/src/database/mod.rs index dcc8989..93e378b 100644 --- a/identity-api-rs/src/database/mod.rs +++ b/identity-api-rs/src/database/mod.rs @@ -20,11 +20,10 @@ use diesel::r2d2::Pool; use crate::env; +pub mod actions; +pub mod list; pub mod models; pub mod schema; -pub mod list; -pub mod actions; - pub fn create_connection_pool() -> Result>, r2d2::Error> { let url = env::database_url(); diff --git a/identity-api-rs/src/database/models.rs b/identity-api-rs/src/database/models.rs index edccdf4..f7f2bdb 100644 --- a/identity-api-rs/src/database/models.rs +++ b/identity-api-rs/src/database/models.rs @@ -18,8 +18,15 @@ use chrono::NaiveDateTime; use diesel::prelude::*; use serde::{Deserialize, Serialize}; -use crate::database::schema; use crate::database::list::List; +use crate::database::schema; + +pub type FullDatabaseEntry = ( + Entry, + Option, + Option, + Option, +); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UniversalId { @@ -29,36 +36,36 @@ pub struct UniversalId { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocationCoordinates { - latitude: f64, - longitude: f64, + pub latitude: f64, + pub longitude: f64, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::date_entries)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct DateEntry { - id: String, - referenced_date: NaiveDateTime, + pub id: String, + pub referenced_date: NaiveDateTime, } #[derive(Queryable, Selectable, Serialize, Deserialize)] #[diesel(table_name = schema::entries)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Entry { - id: String, - user_id: String, - created_at: NaiveDateTime, - feelings: List, - assets: List, - title: Option, - description: Option, - kind: String, - music_entry: Option, - location_entry: Option, - date_entry: Option, + pub id: String, + pub user_id: String, + pub created_at: NaiveDateTime, + pub feelings: List, + pub assets: List, + pub title: Option, + pub description: Option, + pub kind: String, + pub music_entry: Option, + pub location_entry: Option, + pub date_entry: Option, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::heirs)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Heir { @@ -69,7 +76,7 @@ pub struct Heir { pub email: Option, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::limits)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Limit { @@ -78,19 +85,21 @@ pub struct Limit { max_asset_count: i32, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::location_entries)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct LocationEntry { - id: String, - location_text: Option, + pub id: String, + pub location_text: Option, /// JSON value: { latitude: number, longitude: number } - location_coordinates: Option, + pub location_coordinates: Option, } impl LocationEntry { pub fn location_coordinates(&self) -> Option { - self.location_coordinates.as_ref().map(|v| serde_json::from_str(v).unwrap()) + self.location_coordinates + .as_ref() + .map(|v| serde_json::from_str(v).unwrap()) } } @@ -98,14 +107,14 @@ impl LocationEntry { #[diesel(table_name = schema::music_entries)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct MusicEntry { - id: String, - artist: String, - title: String, - links: List, - universal_ids: List, + pub id: String, + pub artist: String, + pub title: String, + pub links: List, + pub universal_ids: List, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::session_keys)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct SessionKey { @@ -113,7 +122,7 @@ pub struct SessionKey { user_id: String, } -#[derive(Queryable, Selectable, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)] #[diesel(table_name = schema::users)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct User { diff --git a/identity-api-rs/src/env.rs b/identity-api-rs/src/env.rs index 1eb7b19..7532e93 100644 --- a/identity-api-rs/src/env.rs +++ b/identity-api-rs/src/env.rs @@ -14,9 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{env, str::FromStr}; use std::sync::OnceLock; use std::time::Duration; +use std::{env, str::FromStr}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey}; @@ -89,14 +89,18 @@ pub fn jwt_secret() -> &'static (EncodingKey, DecodingKey) { let secret = env::var("IDENTITY_API_JWT_SECRET") .expect("environment variables were not loaded correctly"); - (EncodingKey::from_secret(secret.as_bytes()), DecodingKey::from_secret(secret.as_bytes())) + ( + EncodingKey::from_secret(secret.as_bytes()), + DecodingKey::from_secret(secret.as_bytes()), + ) }) } pub fn jwt_alg() -> &'static Algorithm { static IDENTITY_API_JWT_ALG: OnceLock = OnceLock::new(); IDENTITY_API_JWT_ALG.get_or_init(|| { - let algo = env::var("IDENTITY_API_JWT_ALG").expect("environment variables were not loaded correctly"); + let algo = env::var("IDENTITY_API_JWT_ALG") + .expect("environment variables were not loaded correctly"); Algorithm::from_str(&algo).expect("invalid JWT algorithm") }) } diff --git a/identity-api-rs/src/http/entry.rs b/identity-api-rs/src/http/entry.rs new file mode 100644 index 0000000..bbf6a35 --- /dev/null +++ b/identity-api-rs/src/http/entry.rs @@ -0,0 +1,220 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use serde::de::Error as DeError; +use serde::{Deserialize, Serialize}; + +use crate::database::models::{ + DateEntry, FullDatabaseEntry, LocationEntry, MusicEntry, UniversalId, +}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum HttpEntryFeeling { + Builtin(String), + #[serde(rename_all = "camelCase")] + Custom { + identifier: String, + description: String, + background_color: String, + text_color: String, + }, +} + +// FIXME(sofia): Improve this impl +impl TryFrom<&str> for HttpEntryFeeling { + type Error = serde_json::Error; + + fn try_from(value: &str) -> Result { + if value.contains('\"') || value.contains('{') { + let json_value: serde_json::Value = serde_json::from_str(value)?; + let identifier = json_value + .get("identifier") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde_json::Error::custom("Missing or invalid 'identifier' field"))? + .to_owned(); + let description = json_value + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde_json::Error::custom("Missing or invalid 'description' field"))? + .to_owned(); + let background_color = json_value + .get("background_color") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + serde_json::Error::custom("Missing or invalid 'background_color' field") + })? + .to_owned(); + let text_color = json_value + .get("text_color") + .and_then(|v| v.as_str()) + .ok_or_else(|| serde_json::Error::custom("Missing or invalid 'text_color' field"))? + .to_owned(); + + Ok(Self::Custom { + identifier, + description, + background_color, + text_color, + }) + } else { + Ok(Self::Builtin(value.to_owned())) + } + } +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +#[serde(untagged)] +pub enum HttpEntryLocation { + Description(String), + Exact { latitude: f64, longitude: f64 }, +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +#[serde(tag = "kind")] +pub enum HttpEntryBase { + #[serde(rename = "event")] + Event, + #[serde(rename = "memory")] + Memory, + #[serde(rename = "feeling")] + Feeling, + #[serde(rename = "environment")] + Environment { location: Option }, + #[serde(rename = "date")] + #[serde(rename_all = "camelCase")] + Date { referenced_date: String }, + #[serde(rename = "song")] + Song { + artist: String, + title: String, + links: Vec, + id: Vec, + }, + #[serde(rename = "album")] + Album { + artist: String, + title: String, + links: Vec, + id: Vec, + }, +} + +impl HttpEntryBase { + pub fn kind(&self) -> &'static str { + match self { + Self::Event => "event", + Self::Memory => "memory", + Self::Feeling => "feeling", + Self::Environment { .. } => "environment", + Self::Date { .. } => "date", + Self::Song { .. } => "song", + Self::Album { .. } => "album", + } + } + + pub fn from_kind( + kind: &str, + (music_entry, location_entry, date_entry): ( + Option, + Option, + Option, + ), + ) -> Option { + match kind { + "event" => Some(Self::Event), + "memory" => Some(Self::Memory), + "feeling" => Some(Self::Feeling), + "environment" => Some(Self::Environment { + location: location_entry.map(|v| { + if let Some(text) = v.location_text { + HttpEntryLocation::Description(text) + } else { + let coords = v.location_coordinates().unwrap(); + HttpEntryLocation::Exact { + latitude: coords.latitude, + longitude: coords.longitude, + } + } + }), + }), + "date" => Some(Self::Date { + referenced_date: date_entry.unwrap().referenced_date.to_string(), + }), + "song" => { + let music_entry = music_entry.unwrap(); + Some(Self::Song { + artist: music_entry.artist, + title: music_entry.title, + links: music_entry.links.0, + id: music_entry.universal_ids.0, + }) + } + "album" => { + let music_entry = music_entry.unwrap(); + Some(Self::Album { + artist: music_entry.artist, + title: music_entry.title, + links: music_entry.links.0, + id: music_entry.universal_ids.0, + }) + } + _ => None, + } + } +} + +#[derive(Debug, Deserialize, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpEntry { + /// Only `Some` when built by the server + pub id: Option, + pub title: Option, + pub description: Option, + pub creation_date: String, + pub assets: Vec, + pub feelings: Vec, + pub base: HttpEntryBase, +} + +impl TryFrom for HttpEntry { + type Error = &'static str; + + fn try_from( + (entry, music_entry, location_entry, date_entry): FullDatabaseEntry, + ) -> Result { + if let Some(base) = + HttpEntryBase::from_kind(&entry.kind, (music_entry, location_entry, date_entry)) + { + Ok(Self { + id: Some(entry.id), + title: entry.title, + description: entry.description, + creation_date: entry.created_at.to_string(), + assets: entry.assets.0, + feelings: entry + .feelings + .0 + .iter() + .filter_map(|v| v.as_str().try_into().ok()) + .collect(), + base, + }) + } else { + Err("invalid data stored in the database") + } + } +} diff --git a/identity-api-rs/src/http/extractors/auth.rs b/identity-api-rs/src/http/extractors/auth.rs index c56adaf..70b166c 100644 --- a/identity-api-rs/src/http/extractors/auth.rs +++ b/identity-api-rs/src/http/extractors/auth.rs @@ -14,11 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use axum::{async_trait, extract::FromRequestParts, http::{header::AUTHORIZATION, request::Parts, StatusCode}}; -use tracing::{warn, error}; +use crate::auth::JwtUser; use crate::database::{actions, models::User}; use crate::AppState; -use crate::auth::JwtUser; +use axum::{ + async_trait, + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts, StatusCode}, +}; +use tracing::{error, warn}; + +use super::database::Database; + pub struct ExtractJwtUser(pub JwtUser); #[async_trait] @@ -53,23 +60,21 @@ where pub struct ExtractUser(pub User); #[async_trait] -impl FromRequestParts for ExtractUser -{ +impl FromRequestParts for ExtractUser { type Rejection = (StatusCode, &'static str); - async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { - let jwt_user = ExtractJwtUser::from_request_parts(parts, state).await?; + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let ExtractJwtUser(jwt_user) = ExtractJwtUser::from_request_parts(parts, state).await?; + let Database(mut conn) = Database::from_request_parts(parts, state).await?; - if let Ok(mut conn) = state.pool.get() { - if let Ok(user) = actions::user(&jwt_user.0.uid, &mut conn) { - Ok(Self(user)) - } else { - error!("JWT user does not exist in database"); - Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")) - } + if let Ok(user) = actions::user(&jwt_user.uid, &mut conn) { + Ok(Self(user)) } else { - error!("failed to obtain pooled connection"); + error!("JWT user does not exist in database"); Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")) } } -} \ No newline at end of file +} diff --git a/identity-api-rs/src/http/extractors/database.rs b/identity-api-rs/src/http/extractors/database.rs new file mode 100644 index 0000000..24e0ecf --- /dev/null +++ b/identity-api-rs/src/http/extractors/database.rs @@ -0,0 +1,30 @@ +use crate::AppState; +use axum::{ + async_trait, + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts, StatusCode}, +}; +use diesel::{ + r2d2::{ConnectionManager, PooledConnection}, + SqliteConnection, +}; +use tracing::{error, warn}; + +pub struct Database(pub PooledConnection>); + +#[async_trait] +impl FromRequestParts for Database { + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts( + _parts: &mut Parts, + state: &AppState, + ) -> Result { + let conn = state.pool.get().map_err(|err| { + error!("failed to obtain pooled connection: {:?}", err); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") + })?; + + Ok(Self(conn)) + } +} diff --git a/identity-api-rs/src/http/extractors/mod.rs b/identity-api-rs/src/http/extractors/mod.rs index fedf3fe..0b82fe5 100644 --- a/identity-api-rs/src/http/extractors/mod.rs +++ b/identity-api-rs/src/http/extractors/mod.rs @@ -14,4 +14,5 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod auth; \ No newline at end of file +pub mod auth; +pub mod database; diff --git a/identity-api-rs/src/http/mod.rs b/identity-api-rs/src/http/mod.rs index 6c29d39..cbafd54 100644 --- a/identity-api-rs/src/http/mod.rs +++ b/identity-api-rs/src/http/mod.rs @@ -14,5 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod entry; pub mod extractors; -pub mod routes; \ No newline at end of file +pub mod routes; diff --git a/identity-api-rs/src/http/routes/auth.rs b/identity-api-rs/src/http/routes/auth.rs index 5ac3c28..2350a18 100644 --- a/identity-api-rs/src/http/routes/auth.rs +++ b/identity-api-rs/src/http/routes/auth.rs @@ -14,14 +14,29 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use argon2::{password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use axum::{extract::State, http::StatusCode, routing::{get, post, put, delete}, Json, Router}; -use chrono::{Utc, NaiveDateTime}; -use diesel::{QueryDsl, RunQueryDsl, ExpressionMethods}; +use crate::{ + auth::{encode_jwt, expiration_time, JwtUser}, + database::actions, + http::extractors::{ + auth::{ExtractJwtUser, ExtractUser}, + database::Database, + }, + AppState, +}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; +use axum::{ + http::StatusCode, + routing::{delete, get, post, put}, + Json, Router, +}; +use chrono::{NaiveDateTime, Utc}; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; +use serde::{Deserialize, Serialize}; use tracing::{error, info}; -use serde::{Serialize, Deserialize}; use uuid::Uuid; -use crate::{auth::{encode_jwt, expiration_time, JwtUser}, database::actions, http::extractors::auth::{ExtractJwtUser, ExtractUser}, AppState}; pub fn auth_router() -> Router { Router::new() @@ -58,31 +73,27 @@ struct GenkeyResponse { session_key: String, } -async fn genkey(State(state): State, ExtractJwtUser(user): ExtractJwtUser) -> Result, StatusCode> { +async fn genkey( + Database(mut conn): Database, + ExtractJwtUser(user): ExtractJwtUser, +) -> Result, StatusCode> { use crate::database::schema::session_keys::dsl::*; - if let Ok(mut conn) = state.pool.get() { - let session_key = Uuid::new_v4().to_string(); - let result = diesel::insert_into(session_keys) - .values(( - user_id.eq(&user.uid), - key.eq(&session_key), - )) - .execute(&mut conn); + let session_key = Uuid::new_v4().to_string(); + let result = diesel::insert_into(session_keys) + .values((user_id.eq(&user.uid), key.eq(&session_key))) + .execute(&mut conn); - if result.is_ok() { - Ok(Json(GenkeyResponse { - session_key, - })) - } else { - error!("failed to insert into session_keys {}, error: {:?}", user.uid, result.err()); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } + if result.is_ok() { + Ok(Json(GenkeyResponse { session_key })) } else { - error!("failed to obtain pooled connection"); + error!( + "failed to insert into session_keys {}, error: {:?}", + user.uid, + result.err() + ); Err(StatusCode::INTERNAL_SERVER_ERROR) } - } #[derive(Debug, Deserialize)] @@ -96,35 +107,36 @@ struct LoginResponse { token: String, } -async fn login(State(state): State, Json(req): Json) -> Result, StatusCode> { - if let Ok(mut conn) = state.pool.get() { - if let Ok(Some(user)) = actions::user_by_email(&req.email, &mut conn) { - let parsed_hash = PasswordHash::new(&user.password).expect("invalid argon2 password hash"); - if Argon2::default().verify_password(req.password.as_bytes(), &parsed_hash).is_err() { - info!("failed login attempt, invalid password: {}", &req.email); - Err(StatusCode::UNAUTHORIZED) - } else { - info!("valid login attempt: {}", req.email); - match encode_jwt(&JwtUser { - uid: user.id, - email: user.email, - name: user.name, - exp: expiration_time(), - }) { - Ok(token) => Ok(Json(LoginResponse { token })), - Err(err) => { - error!("token couldn't be encoded: {:?}", err); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } +async fn login( + Database(mut conn): Database, + Json(req): Json, +) -> Result, StatusCode> { + if let Ok(Some(user)) = actions::user_by_email(&req.email, &mut conn) { + let parsed_hash = PasswordHash::new(&user.password).expect("invalid argon2 password hash"); + if Argon2::default() + .verify_password(req.password.as_bytes(), &parsed_hash) + .is_err() + { + info!("failed login attempt, invalid password: {}", &req.email); + Err(StatusCode::UNAUTHORIZED) + } else { + info!("valid login attempt: {}", req.email); + match encode_jwt(&JwtUser { + uid: user.id, + email: user.email, + name: user.name, + exp: expiration_time(), + }) { + Ok(token) => Ok(Json(LoginResponse { token })), + Err(err) => { + error!("token couldn't be encoded: {:?}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) } } - } else { - info!("failed login attempt, email does not exist: {}", &req.email); - Err(StatusCode::UNAUTHORIZED) } } else { - error!("failed to obtain pooled connection"); - Err(StatusCode::INTERNAL_SERVER_ERROR) + info!("failed login attempt, email does not exist: {}", &req.email); + Err(StatusCode::UNAUTHORIZED) } } @@ -140,81 +152,90 @@ struct RegisterResponse { token: String, } -async fn register(State(state): State, Json(req): Json) -> Result, StatusCode> { - use crate::database::schema::users::dsl as users; +async fn register( + Database(mut conn): Database, + Json(req): Json, +) -> Result, StatusCode> { use crate::database::schema::limits::dsl as limits; + use crate::database::schema::users::dsl as users; - if let Ok(mut conn) = state.pool.get() { - let user = actions::user_by_email(&req.email, &mut conn); + let user = actions::user_by_email(&req.email, &mut conn); - if user.is_err() { - error!("failed to retrieve potential existing user from database: {}, error: {:?}", &req.email, user.err()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } + if user.is_err() { + error!( + "failed to retrieve potential existing user from database: {}, error: {:?}", + &req.email, + user.err() + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } - if user.is_ok_and(|v| v.is_some()) { - info!("tried to register existing user: {}", &req.email); - return Err(StatusCode::BAD_REQUEST); - } + if user.is_ok_and(|v| v.is_some()) { + info!("tried to register existing user: {}", &req.email); + return Err(StatusCode::BAD_REQUEST); + } - let limit_id = Uuid::new_v4().to_string(); - let result = diesel::insert_into(limits::limits) + let limit_id = Uuid::new_v4().to_string(); + let result = diesel::insert_into(limits::limits) + .values(( + limits::id.eq(&limit_id), + limits::current_asset_count.eq(0), + limits::max_asset_count.eq(10), + )) + .execute(&mut conn); + + if result.is_err() { + error!( + "failed to insert into limits: {}, error: {:?}", + &req.email, + result.err() + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(req.password.as_bytes(), &salt); + + if let Ok(password_hash) = password_hash { + let user_id = Uuid::new_v4().to_string(); + let result = diesel::insert_into(users::users) .values(( - limits::id.eq(&limit_id), - limits::current_asset_count.eq(0), - limits::max_asset_count.eq(10), + users::id.eq(&user_id), + users::created_at.eq(Utc::now().naive_utc()), + users::last_connected_at.eq(Utc::now().naive_utc()), + users::email.eq(&req.email), + users::password.eq(password_hash.to_string()), + users::name.eq(&req.name), + users::limits.eq(&limit_id), + // FIXME(sofia): Implement diesel::Expression for List + users::assets.eq("[]"), )) .execute(&mut conn); if result.is_err() { - error!("failed to insert into limits: {}, error: {:?}", &req.email, result.err()); + error!( + "failed to insert into users: {}, error: {:?}", + req.email, + result.err() + ); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let password_hash = argon2.hash_password(req.password.as_bytes(), &salt); - - if let Ok(password_hash) = password_hash { - let user_id = Uuid::new_v4().to_string(); - let result = diesel::insert_into(users::users) - .values(( - users::id.eq(&user_id), - users::created_at.eq(Utc::now().naive_utc()), - users::last_connected_at.eq(Utc::now().naive_utc()), - users::email.eq(&req.email), - users::password.eq(password_hash.to_string()), - users::name.eq(&req.name), - users::limits.eq(&limit_id), - // TODO: Implement diesel::Expression for List - users::assets.eq("[]"), - )) - .execute(&mut conn); - - if result.is_err() { - error!("failed to insert into users: {}, error: {:?}", req.email, result.err()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); + match crate::auth::encode_jwt(&JwtUser { + uid: user_id, + email: req.email, + name: req.name, + exp: expiration_time(), + }) { + Ok(token) => Ok(Json(RegisterResponse { token })), + Err(err) => { + error!("token couldn't be encoded: {:?}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) } - - match crate::auth::encode_jwt(&JwtUser { - uid: user_id, - email: req.email, - name: req.name, - exp: expiration_time(), - }) { - Ok(token) => Ok(Json(RegisterResponse { token })), - Err(err) => { - error!("token couldn't be encoded: {:?}", err); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } - } else { - error!("failed to hash password: {:?}", password_hash.err()); - Err(StatusCode::INTERNAL_SERVER_ERROR) } - } else { - error!("failed to obtain pooled connection"); + error!("failed to hash password: {:?}", password_hash.err()); Err(StatusCode::INTERNAL_SERVER_ERROR) } } @@ -235,22 +256,24 @@ impl From for HttpHeir { // Only e-mail is implemented right now contact_method: "email".into(), name: value.name, - value: value.email.unwrap() + value: value.email.unwrap(), } } } -async fn list_heirs(State(state): State, ExtractJwtUser(user): ExtractJwtUser) -> Result>, StatusCode> { - if let Ok(mut conn) = state.pool.get() { - let result = actions::list_heirs(&user.uid, &mut conn); - if let Ok(heirs) = result { - Ok(Json(heirs.into_iter().map(HttpHeir::from).collect())) - } else { - error!("failed to obtain heirs: {}, error: {:?}", user.uid, result.err()); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } +async fn list_heirs( + Database(mut conn): Database, + ExtractJwtUser(user): ExtractJwtUser, +) -> Result>, StatusCode> { + let result = actions::list_heirs(&user.uid, &mut conn); + if let Ok(heirs) = result { + Ok(Json(heirs.into_iter().map(HttpHeir::from).collect())) } else { - error!("failed to obtain pooled connection"); + error!( + "failed to obtain heirs: {}, error: {:?}", + user.uid, + result.err() + ); Err(StatusCode::INTERNAL_SERVER_ERROR) } } @@ -258,69 +281,84 @@ async fn list_heirs(State(state): State, ExtractJwtUser(user): Extract #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct InsertHeirRequest { - contact_method: String, - name: String, - value: String, + contact_method: String, + name: String, + value: String, } -async fn insert_heir(State(state): State, ExtractJwtUser(user): ExtractJwtUser, Json(req): Json) -> Result>, StatusCode> { +async fn insert_heir( + Database(mut conn): Database, + ExtractJwtUser(user): ExtractJwtUser, + Json(req): Json, +) -> Result>, StatusCode> { use crate::database::schema::heirs::dsl::*; - if let Ok(mut conn) = state.pool.get() { - let heir_id = Uuid::new_v4().to_string(); - let result = diesel::insert_into(heirs) - .values(( - id.eq(heir_id), - created_at.eq(Utc::now().naive_utc()), - user_id.eq(&user.uid), - name.eq(req.name), - // Only e-mail is implemented right now - email.eq(req.value), - )) - .execute(&mut conn); - if result.is_err() { - error!("failed to insert into heirs: {}, error: {:?}", user.uid, result.err()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } + let heir_id = Uuid::new_v4().to_string(); + let result = diesel::insert_into(heirs) + .values(( + id.eq(heir_id), + created_at.eq(Utc::now().naive_utc()), + user_id.eq(&user.uid), + name.eq(req.name), + // Only e-mail is implemented right now + email.eq(req.value), + )) + .execute(&mut conn); - let result = actions::list_heirs(&user.uid, &mut conn); - if let Ok(heirs_list) = result { - Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect())) - } else { - error!("failed to obtain heirs: {}, error: {:?}", user.uid, result.err()); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } + if result.is_err() { + error!( + "failed to insert into heirs: {}, error: {:?}", + user.uid, + result.err() + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let result = actions::list_heirs(&user.uid, &mut conn); + if let Ok(heirs_list) = result { + Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect())) } else { - error!("failed to obtain pooled connection"); + error!( + "failed to obtain heirs: {}, error: {:?}", + user.uid, + result.err() + ); Err(StatusCode::INTERNAL_SERVER_ERROR) } } - #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] struct DeleteHeirRequest { - id: String, + id: String, } -async fn delete_heir(State(state): State, ExtractJwtUser(user): ExtractJwtUser, Json(req): Json) -> Result>, StatusCode> { +async fn delete_heir( + Database(mut conn): Database, + ExtractJwtUser(user): ExtractJwtUser, + Json(req): Json, +) -> Result>, StatusCode> { use crate::database::schema::heirs::dsl::*; - if let Ok(mut conn) = state.pool.get() { - let result = diesel::delete(heirs.filter(id.eq(&req.id))).execute(&mut conn); - if result.is_err() { - error!("failed to delete from heirs: {}, heir_id: {}, error: {:?}", user.uid, req.id, result.err()); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - let result = actions::list_heirs(&user.uid, &mut conn); - if let Ok(heirs_list) = result { - Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect())) - } else { - error!("failed to obtain heirs: {}, error: {:?}", user.uid, result.err()); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } + let result = diesel::delete(heirs.filter(id.eq(&req.id))).execute(&mut conn); + if result.is_err() { + error!( + "failed to delete from heirs: {}, heir_id: {}, error: {:?}", + user.uid, + req.id, + result.err() + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let result = actions::list_heirs(&user.uid, &mut conn); + if let Ok(heirs_list) = result { + Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect())) } else { - error!("failed to obtain pooled connection"); + error!( + "failed to obtain heirs: {}, error: {:?}", + user.uid, + result.err() + ); Err(StatusCode::INTERNAL_SERVER_ERROR) } -} \ No newline at end of file +} diff --git a/identity-api-rs/src/http/routes/entry.rs b/identity-api-rs/src/http/routes/entry.rs new file mode 100644 index 0000000..4cfd978 --- /dev/null +++ b/identity-api-rs/src/http/routes/entry.rs @@ -0,0 +1,265 @@ +// Identity. Store your memories and mental belongings +// Copyright (C) 2024 Sofía Aritz +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::{ + database::{ + actions, + list::List, + models::{DateEntry, LocationEntry, MusicEntry}, + }, + http::{ + entry::*, + extractors::{ + auth::{ExtractJwtUser, ExtractUser}, + database::Database, + }, + }, + AppState, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + routing::{delete, get, put}, + Json, Router, +}; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; +use serde::Deserialize; +use serde_json::json; +use tracing::{error, info, warn}; +use uuid::Uuid; + +pub fn entry_router() -> Router { + Router::new() + .route("/entry", delete(delete_entry)) + .route("/entry", put(insert_entry)) + .route("/entry/list", get(list_entries)) +} + +#[derive(Debug, Deserialize)] +struct DeleteEntryQuery { + entry_id: String, +} + +// FIXME(sofia): Error on non existent entry_id +async fn delete_entry( + Database(mut conn): Database, + Query(query): Query, + ExtractJwtUser(user): ExtractJwtUser, +) -> Result<(), StatusCode> { + use crate::database::schema::entries::dsl::*; + + if let Err(err) = diesel::delete(entries.filter(id.eq(&query.entry_id))).execute(&mut conn) { + error!( + "failed to delete from heirs: {}, entry_id: {}, error: {:?}", + user.uid, query.entry_id, err + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + info!("deleted entry {}", query.entry_id); + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct ListEntriesQuery { + offset: i64, + limit: i64, +} + +async fn list_entries( + Database(mut conn): Database, + Query(query): Query, + ExtractUser(user): ExtractUser, +) -> Result>, StatusCode> { + let result = actions::list_entries_recursive(&user.id, query.offset, query.limit, &mut conn); + if let Ok(entries) = result { + Ok(Json( + entries + .into_iter() + .filter_map(|v| HttpEntry::try_from(v).ok()) + .collect(), + )) + } else { + error!("failed to obtain entries {}: {:?}", user.id, result.err()); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } +} + +#[derive(Debug, Deserialize)] +struct InsertEntryBody { + entry: HttpEntry, +} + +async fn insert_entry( + Database(mut conn): Database, + ExtractUser(user): ExtractUser, + Json(entry): Json, +) -> Result<(), StatusCode> { + let mut music_entry: Option = None; + let mut location_entry: Option = None; + let mut date_entry: Option = None; + + let entry = entry.entry; + match entry.base { + HttpEntryBase::Album { ref artist, ref title, ref links, ref id } + | HttpEntryBase::Song { ref artist, ref title, ref links, ref id } => { + music_entry = Some(MusicEntry { + id: Uuid::new_v4().to_string(), + // FIXME(sofia): These clones seems unnecesary + title: title.to_owned(), + links: links.clone().into(), + artist: artist.clone(), + universal_ids: id.clone().into(), + }) + }, + HttpEntryBase::Environment { ref location } => { + if entry.title.as_ref().is_none_or(|v| v.is_empty()) { + warn!( + "no title in request for inserting environment entry: {}", + user.id + ); + return Err(StatusCode::BAD_REQUEST); + } + + if let Some(location) = location { + match location { + HttpEntryLocation::Description(description) => { + location_entry = Some(LocationEntry { + id: Uuid::new_v4().to_string(), + // FIXME(sofia): This clone seems unnecesary + location_text: Some(description.clone()), + location_coordinates: None, + }) + } + HttpEntryLocation::Exact { + latitude, + longitude, + } => { + location_entry = Some(LocationEntry { + id: Uuid::new_v4().to_string(), + location_text: None, + location_coordinates: Some( + json!({ + "latitude": latitude, + "longitude": longitude, + }) + .to_string(), + ), + }) + } + } + } + } + HttpEntryBase::Date { + ref referenced_date, + } => { + let naive_date = NaiveDate::parse_from_str(referenced_date, "%Y-%m-%d"); + if let Err(err) = naive_date { + warn!( + "invalid date in request for inserting entry: {}, err: {err:?}", + user.id + ); + return Err(StatusCode::BAD_REQUEST); + } + + date_entry = Some(DateEntry { + id: Uuid::new_v4().to_string(), + referenced_date: NaiveDateTime::new( + naive_date.unwrap(), + NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap(), + ), + }); + } + HttpEntryBase::Event => { + if entry.description.as_ref().is_none_or(|v| v.is_empty()) { + warn!( + "no description or title in request for inserting event entry: {}", + user.id + ); + return Err(StatusCode::BAD_REQUEST); + } + } + HttpEntryBase::Memory => { + if entry.description.as_ref().is_none_or(|v| v.is_empty()) + || entry.title.as_ref().is_none_or(|v| v.is_empty()) + { + warn!( + "no description or title in request for inserting memory entry: {}", + user.id + ); + return Err(StatusCode::BAD_REQUEST); + } + } + HttpEntryBase::Feeling => { + if entry.feelings.is_empty() { + warn!( + "no feelings in request for inserting feeling entry: {}", + user.id + ); + return Err(StatusCode::BAD_REQUEST); + } + } + } + + let music_entry_id = music_entry.as_ref().map(|v| v.id.clone()); + music_entry.map(|music_entry| actions::insert_music_entry(&music_entry, &mut conn).map_err(|err| { + error!("failed to insert into music_entries: {}, error: {err:?}",user.id); + StatusCode::INTERNAL_SERVER_ERROR + })).transpose()?; + + let location_entry_id = location_entry.as_ref().map(|v| v.id.clone()); + location_entry.map(|location_entry| actions::insert_location_entry(&location_entry, &mut conn).map_err(|err| { + error!("failed to insert into location_entries: {}, error: {err:?}",user.id); + StatusCode::INTERNAL_SERVER_ERROR + })).transpose()?; + + let date_entry_id = date_entry.as_ref().map(|v| v.id.clone()); + date_entry.map(|date_entry| actions::insert_date_entry(&date_entry, &mut conn).map_err(|err| { + error!("failed to insert into date_entries: {}, error: {err:?}",user.id); + StatusCode::INTERNAL_SERVER_ERROR + })).transpose()?; + + { + use crate::database::schema::entries::dsl as entries; + let result = diesel::insert_into(entries::entries) + .values(( + entries::id.eq(Uuid::new_v4().to_string()), + entries::user_id.eq(&user.id), + entries::created_at.eq(Utc::now().naive_utc()), + entries::feelings.eq(List::from(entry.feelings).to_string()), + // FIXME(sofia): Check that the assets exists + entries::assets.eq(List::from(entry.assets).to_string()), + entries::title.eq(&entry.title), + entries::description.eq(&entry.description), + entries::kind.eq(&entry.base.kind()), + entries::date_entry.eq(date_entry_id), + entries::music_entry.eq(music_entry_id), + entries::location_entry.eq(location_entry_id), + )) + .execute(&mut conn); + + if let Err(err) = result { + error!( + "failed to insert into entries: {}, error: {:?}", + user.id, err + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + Ok(()) +} diff --git a/identity-api-rs/src/http/routes/mod.rs b/identity-api-rs/src/http/routes/mod.rs index fedf3fe..71d219c 100644 --- a/identity-api-rs/src/http/routes/mod.rs +++ b/identity-api-rs/src/http/routes/mod.rs @@ -14,4 +14,5 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod auth; \ No newline at end of file +pub mod auth; +pub mod entry; diff --git a/identity-api-rs/src/main.rs b/identity-api-rs/src/main.rs index 42bb4e8..79d52ec 100644 --- a/identity-api-rs/src/main.rs +++ b/identity-api-rs/src/main.rs @@ -14,21 +14,34 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use axum::{extract::{MatchedPath, Request}, response::Response, routing::get, Router}; +use axum::{ + extract::{MatchedPath, Request}, + http::{ + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + Method, + }, + response::Response, + routing::get, + Router, +}; use database::create_connection_pool; use diesel::{r2d2::ConnectionManager, SqliteConnection}; use env::{listen_port, LoadEnvError}; -use http::routes::auth::auth_router; +use http::routes::{auth::auth_router, entry::entry_router}; use r2d2::Pool; -use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; -use tracing::{info, info_span, warn, error, Span}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tokio::time::Duration; +use tower_http::{ + classify::ServerErrorsFailureClass, + cors::{Any, CorsLayer}, + trace::TraceLayer, +}; +use tracing::{error, info, info_span, warn, Span}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod auth; mod database; mod env; mod http; -mod auth; #[derive(Clone)] struct AppState { @@ -59,6 +72,13 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); + // FIXME(sofia): Add an cors config in env vars + let cors = CorsLayer::new() + .allow_methods(vec![Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]) + .allow_origin(Any) + .allow_credentials(false); + let state = AppState { pool: create_connection_pool().expect("failed to create database connection pool"), }; @@ -66,6 +86,7 @@ async fn main() { let app = Router::new() .route("/", get(landing)) .merge(auth_router()) + .merge(entry_router()) .with_state(state) .layer( TraceLayer::new_for_http() @@ -83,23 +104,18 @@ async fn main() { }) .on_response(|response: &Response, _latency: Duration, _span: &Span| { if response.status().is_client_error() { - warn!( - "client error: {}", - response.status().to_string() - ); + warn!("client error: {}", response.status().to_string()); } else { info!("finished processing request"); } }) .on_failure( |error: ServerErrorsFailureClass, _latency: Duration, _span: &Span| { - error!( - "internal server error: {}", - error.to_string(), - ); + error!("internal server error: {}", error.to_string(),); }, ), - ); + ) + .layer(cors); // FIXME(sofia): Add an env var to change the bind addr let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", listen_port())) diff --git a/identity-api/src/database.ts b/identity-api/src/database.ts index f9ece36..25fd96a 100644 --- a/identity-api/src/database.ts +++ b/identity-api/src/database.ts @@ -215,7 +215,7 @@ export async function startDatabase() { let musicDetails = ( await database.select().from(musicEntries).where(eq(musicEntries.id, entry.musicEntry)) )[0]; - (musicDetails["link"] as any) = fromDBList(musicDetails.links); + (musicDetails["links"] as any) = fromDBList(musicDetails.links); (musicDetails["id"] as any) = fromDBList(musicDetails.universalIDs); musicDetails["links"] = undefined; diff --git a/identity-api/src/routes/entry/index.ts b/identity-api/src/routes/entry/index.ts index 16aec97..cae4d64 100644 --- a/identity-api/src/routes/entry/index.ts +++ b/identity-api/src/routes/entry/index.ts @@ -53,7 +53,7 @@ const PutEntryBody = Type.Object({ kind: Type.String(), artist: Type.String(), title: Type.String(), - link: Type.Array(Type.String()), + links: Type.Array(Type.String()), id: Type.Array( Type.Object({ provider: Type.String(), @@ -111,7 +111,7 @@ export default function registerRoutes(app: AppInterface, auth: AuthInterface, d id: randomUUID(), title: entry.base.title, artist: entry.base.artist, - links: toDBList(entry.base.link), + links: toDBList(entry.base.links), universalIDs: toDBList(entry.base.id), }; } else if (entry.base.kind === "environment" && "location" in entry.base) { diff --git a/identity-web/src/lib/entry.ts b/identity-web/src/lib/entry.ts index 88ca8f5..b038e72 100644 --- a/identity-web/src/lib/entry.ts +++ b/identity-web/src/lib/entry.ts @@ -115,7 +115,7 @@ export type SongEntry = { kind: 'song'; artist: string; title: string; - link: string[]; + links: string[]; id: UniversalID[]; }; @@ -123,7 +123,7 @@ export type AlbumEntry = { kind: 'album'; artist: string; title: string; - link: string[]; + links: string[]; id: UniversalID[]; }; diff --git a/identity-web/src/routes/dashboard/Entries.svelte b/identity-web/src/routes/dashboard/Entries.svelte index e6a5c25..98fb450 100644 --- a/identity-web/src/routes/dashboard/Entries.svelte +++ b/identity-web/src/routes/dashboard/Entries.svelte @@ -121,8 +121,8 @@ >
{#if entry.base.kind === 'song' || entry.base.kind === 'album'} - {#if entry.base.link[0] != null} - + {#if entry.base.links[0] != null} + {entry.base.artist} ‐ {entry.base.title} {:else} @@ -163,8 +163,8 @@
{#if entry.base.kind === 'song' || entry.base.kind === 'album'} - {#if entry.base.link[0] != null} - + {#if entry.base.links[0] != null} + {entry.base.artist} ‐ {entry.base.title} {:else} diff --git a/identity-web/src/routes/entry/new/+page.svelte b/identity-web/src/routes/entry/new/+page.svelte index 680dc4c..e16d982 100644 --- a/identity-web/src/routes/entry/new/+page.svelte +++ b/identity-web/src/routes/entry/new/+page.svelte @@ -38,7 +38,7 @@ kind: values.kind, artist: values.artist, title: values.musicTitle, - link: [values.spotify, values.yt, values.otherProvider].filter( + links: [values.spotify, values.yt, values.otherProvider].filter( (v) => v != null && v.length > 0 ), // FIXME: Infer Universal IDs (Spotify URL, etc)