Compare commits
No commits in common. "rust-api" and "main" have entirely different histories.
9
identity-api-rs/.gitignore
vendored
9
identity-api-rs/.gitignore
vendored
|
@ -1,9 +0,0 @@
|
||||||
/target
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
.database/*
|
|
||||||
!.database/.gitkeep
|
|
||||||
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
|
@ -1,20 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "identity-api"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
argon2 = "0.5.3"
|
|
||||||
axum = { version = "0.7", features = ["macros", "tracing"] }
|
|
||||||
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"] }
|
|
||||||
diesel = { version = "2.2", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "r2d2"] }
|
|
||||||
dotenvy = "0.15"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
r2d2 = "0.8"
|
|
||||||
jsonwebtoken = "9"
|
|
||||||
uuid = { version = "1.10", features = ["v4", "fast-rng"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
|
@ -1,9 +0,0 @@
|
||||||
# For documentation on how to configure this file,
|
|
||||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
|
||||||
|
|
||||||
[print_schema]
|
|
||||||
file = "src/database/schema.rs"
|
|
||||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
|
||||||
|
|
||||||
[migrations_directory]
|
|
||||||
dir = "migrations"
|
|
|
@ -1,8 +0,0 @@
|
||||||
DROP TABLE IF EXISTS date_entries;
|
|
||||||
DROP TABLE IF EXISTS location_entries;
|
|
||||||
DROP TABLE IF EXISTS music_entries;
|
|
||||||
DROP TABLE IF EXISTS entries;
|
|
||||||
DROP TABLE IF EXISTS heirs;
|
|
||||||
DROP TABLE IF EXISTS session_keys;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
DROP TABLE IF EXISTS limits;
|
|
|
@ -1,69 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS limits (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
current_asset_count integer NOT NULL,
|
|
||||||
max_asset_count integer NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
created_at timestamp NOT NULL,
|
|
||||||
last_connected_at timestamp NOT NULL,
|
|
||||||
email varchar NOT NULL,
|
|
||||||
password varchar NOT NULL,
|
|
||||||
name varchar NOT NULL,
|
|
||||||
limits varchar NOT NULL,
|
|
||||||
assets varchar NOT NULL,
|
|
||||||
FOREIGN KEY (limits) REFERENCES limits (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS session_keys (
|
|
||||||
key varchar PRIMARY KEY NOT NULL,
|
|
||||||
user_id varchar NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS heirs (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
user_id varchar NOT NULL,
|
|
||||||
created_at timestamp NOT NULL,
|
|
||||||
name varchar NOT NULL,
|
|
||||||
email varchar,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
user_id varchar NOT NULL,
|
|
||||||
created_at timestamp NOT NULL,
|
|
||||||
feelings text NOT NULL,
|
|
||||||
assets text NOT NULL,
|
|
||||||
title text,
|
|
||||||
description text,
|
|
||||||
kind varchar NOT NULL,
|
|
||||||
music_entry varchar,
|
|
||||||
location_entry varchar,
|
|
||||||
date_entry varchar,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
|
||||||
FOREIGN KEY (music_entry) REFERENCES music_entries (id),
|
|
||||||
FOREIGN KEY (location_entry) REFERENCES location_entries (id),
|
|
||||||
FOREIGN KEY (date_entry) REFERENCES date_entries (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS music_entries (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
artist varchar NOT NULL,
|
|
||||||
title varchar NOT NULL,
|
|
||||||
links text NOT NULL,
|
|
||||||
universal_ids text NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS location_entries (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
location_text text,
|
|
||||||
location_coordinates varchar
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS date_entries (
|
|
||||||
id varchar PRIMARY KEY NOT NULL,
|
|
||||||
referenced_date timestamp NOT NULL
|
|
||||||
);
|
|
|
@ -1,45 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
use crate::env;
|
|
||||||
use jsonwebtoken::{Header, TokenData, Validation};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct JwtUser {
|
|
||||||
pub uid: String,
|
|
||||||
pub email: String,
|
|
||||||
pub name: String,
|
|
||||||
pub exp: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_jwt(claims: &JwtUser) -> jsonwebtoken::errors::Result<String> {
|
|
||||||
jsonwebtoken::encode(&Header::new(*env::jwt_alg()), claims, &env::jwt_secret().0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_jwt(jwt: &str) -> jsonwebtoken::errors::Result<TokenData<JwtUser>> {
|
|
||||||
jsonwebtoken::decode::<JwtUser>(jwt, &env::jwt_secret().1, &Validation::new(*env::jwt_alg()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn expiration_time() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
|
||||||
.expect("time went backwards")
|
|
||||||
.as_secs()
|
|
||||||
+ 30 * 24 * 3600
|
|
||||||
}
|
|
|
@ -1,133 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use diesel::{
|
|
||||||
r2d2::{ConnectionManager, PooledConnection},
|
|
||||||
result::QueryResult,
|
|
||||||
ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper,
|
|
||||||
SqliteConnection,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::models::{DateEntry, Entry, FullDatabaseEntry, Heir, LocationEntry, MusicEntry, User};
|
|
||||||
|
|
||||||
type Connection<'a> = &'a mut PooledConnection<ConnectionManager<SqliteConnection>>;
|
|
||||||
|
|
||||||
pub fn user(user_id: &str, conn: Connection) -> QueryResult<User> {
|
|
||||||
use crate::database::schema::users::dsl::users;
|
|
||||||
users.find(user_id).select(User::as_select()).first(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_by_email(email: &str, conn: Connection) -> QueryResult<Option<User>> {
|
|
||||||
use crate::database::schema::users::dsl as users;
|
|
||||||
users::users
|
|
||||||
.filter(users::email.eq(email))
|
|
||||||
.limit(1)
|
|
||||||
.select(User::as_select())
|
|
||||||
.first(conn)
|
|
||||||
.optional()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_heirs(user_id: &str, conn: Connection) -> QueryResult<Vec<Heir>> {
|
|
||||||
use crate::database::schema::heirs::dsl as heirs;
|
|
||||||
heirs::heirs
|
|
||||||
.filter(heirs::user_id.eq(user_id))
|
|
||||||
.select(Heir::as_select())
|
|
||||||
.load(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<FullDatabaseEntry> {
|
|
||||||
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<Vec<FullDatabaseEntry>> {
|
|
||||||
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::<String>(conn)?;
|
|
||||||
entry_ids
|
|
||||||
.iter()
|
|
||||||
.map(|id| entry_recursive(id, conn))
|
|
||||||
.collect()
|
|
||||||
}
|
|
|
@ -1,79 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use diesel::{
|
|
||||||
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)]
|
|
||||||
#[repr(transparent)]
|
|
||||||
pub struct List<V: std::fmt::Debug + std::clone::Clone>(pub Vec<V>);
|
|
||||||
|
|
||||||
impl<V: std::fmt::Debug + std::clone::Clone> List<V> {
|
|
||||||
fn from_vec(vec: Vec<V>) -> Self {
|
|
||||||
Self(vec)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_vec(self) -> Vec<V> {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A: Serialize + std::fmt::Debug + std::clone::Clone> Display for List<A> {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&serde_json::to_string(&self).unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A> From<String> for List<A>
|
|
||||||
where
|
|
||||||
A: DeserializeOwned + std::fmt::Debug + std::clone::Clone,
|
|
||||||
{
|
|
||||||
fn from(value: String) -> Self {
|
|
||||||
serde_json::from_str(&value).expect("failed to deserialize list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A: std::fmt::Debug + std::clone::Clone> From<Vec<A>> for List<A> {
|
|
||||||
fn from(value: Vec<A>) -> Self {
|
|
||||||
Self(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<V: std::fmt::Debug + std::clone::Clone + DeserializeOwned> FromSql<Text, Sqlite> for List<V> {
|
|
||||||
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
|
||||||
let str = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
|
|
||||||
Ok(List::from(str))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<V: std::fmt::Debug + std::clone::Clone + Serialize> ToSql<Text, Sqlite> for List<V> {
|
|
||||||
fn to_sql<'b>(
|
|
||||||
&'b self,
|
|
||||||
out: &mut diesel::serialize::Output<'b, '_, Sqlite>,
|
|
||||||
) -> diesel::serialize::Result {
|
|
||||||
let val = self.to_string();
|
|
||||||
out.set_value(val);
|
|
||||||
Ok(diesel::serialize::IsNull::No)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use diesel::r2d2::ConnectionManager;
|
|
||||||
use diesel::r2d2::Pool;
|
|
||||||
|
|
||||||
use crate::env;
|
|
||||||
|
|
||||||
pub mod actions;
|
|
||||||
pub mod list;
|
|
||||||
pub mod models;
|
|
||||||
pub mod schema;
|
|
||||||
|
|
||||||
pub fn create_connection_pool() -> Result<Pool<ConnectionManager<SqliteConnection>>, r2d2::Error> {
|
|
||||||
let url = env::database_url();
|
|
||||||
let manager = ConnectionManager::<SqliteConnection>::new(url);
|
|
||||||
Pool::builder().build(manager)
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::database::list::List;
|
|
||||||
use crate::database::schema;
|
|
||||||
|
|
||||||
pub type FullDatabaseEntry = (
|
|
||||||
Entry,
|
|
||||||
Option<MusicEntry>,
|
|
||||||
Option<LocationEntry>,
|
|
||||||
Option<DateEntry>,
|
|
||||||
);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UniversalId {
|
|
||||||
provider: String,
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LocationCoordinates {
|
|
||||||
pub latitude: f64,
|
|
||||||
pub longitude: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::date_entries)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct DateEntry {
|
|
||||||
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 {
|
|
||||||
pub id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub feelings: List<String>,
|
|
||||||
pub assets: List<String>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub kind: String,
|
|
||||||
pub music_entry: Option<String>,
|
|
||||||
pub location_entry: Option<String>,
|
|
||||||
pub date_entry: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::heirs)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct Heir {
|
|
||||||
pub id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub name: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::limits)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct Limit {
|
|
||||||
id: String,
|
|
||||||
current_asset_count: i32,
|
|
||||||
max_asset_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::location_entries)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct LocationEntry {
|
|
||||||
pub id: String,
|
|
||||||
pub location_text: Option<String>,
|
|
||||||
/// JSON value: { latitude: number, longitude: number }
|
|
||||||
pub location_coordinates: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocationEntry {
|
|
||||||
pub fn location_coordinates(&self) -> Option<LocationCoordinates> {
|
|
||||||
self.location_coordinates
|
|
||||||
.as_ref()
|
|
||||||
.map(|v| serde_json::from_str(v).unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::music_entries)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct MusicEntry {
|
|
||||||
pub id: String,
|
|
||||||
pub artist: String,
|
|
||||||
pub title: String,
|
|
||||||
pub links: List<String>,
|
|
||||||
pub universal_ids: List<UniversalId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::session_keys)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct SessionKey {
|
|
||||||
key: String,
|
|
||||||
user_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Queryable, Selectable, Insertable, Serialize, Deserialize)]
|
|
||||||
#[diesel(table_name = schema::users)]
|
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
||||||
pub struct User {
|
|
||||||
pub id: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub last_connected_at: NaiveDateTime,
|
|
||||||
pub email: String,
|
|
||||||
pub password: String,
|
|
||||||
pub name: String,
|
|
||||||
pub limits: String,
|
|
||||||
pub assets: String,
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
// @generated automatically by Diesel CLI.
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
date_entries (id) {
|
|
||||||
id -> Text,
|
|
||||||
referenced_date -> Timestamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
entries (id) {
|
|
||||||
id -> Text,
|
|
||||||
user_id -> Text,
|
|
||||||
created_at -> Timestamp,
|
|
||||||
feelings -> Text,
|
|
||||||
assets -> Text,
|
|
||||||
title -> Nullable<Text>,
|
|
||||||
description -> Nullable<Text>,
|
|
||||||
kind -> Text,
|
|
||||||
music_entry -> Nullable<Text>,
|
|
||||||
location_entry -> Nullable<Text>,
|
|
||||||
date_entry -> Nullable<Text>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
heirs (id) {
|
|
||||||
id -> Text,
|
|
||||||
user_id -> Text,
|
|
||||||
created_at -> Timestamp,
|
|
||||||
name -> Text,
|
|
||||||
email -> Nullable<Text>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
limits (id) {
|
|
||||||
id -> Text,
|
|
||||||
current_asset_count -> Integer,
|
|
||||||
max_asset_count -> Integer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
location_entries (id) {
|
|
||||||
id -> Text,
|
|
||||||
location_text -> Nullable<Text>,
|
|
||||||
location_coordinates -> Nullable<Text>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
music_entries (id) {
|
|
||||||
id -> Text,
|
|
||||||
artist -> Text,
|
|
||||||
title -> Text,
|
|
||||||
links -> Text,
|
|
||||||
universal_ids -> Text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
session_keys (key) {
|
|
||||||
key -> Text,
|
|
||||||
user_id -> Text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::table! {
|
|
||||||
users (id) {
|
|
||||||
id -> Text,
|
|
||||||
created_at -> Timestamp,
|
|
||||||
last_connected_at -> Timestamp,
|
|
||||||
email -> Text,
|
|
||||||
password -> Text,
|
|
||||||
name -> Text,
|
|
||||||
limits -> Text,
|
|
||||||
assets -> Text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::joinable!(entries -> date_entries (date_entry));
|
|
||||||
diesel::joinable!(entries -> location_entries (location_entry));
|
|
||||||
diesel::joinable!(entries -> music_entries (music_entry));
|
|
||||||
diesel::joinable!(entries -> users (user_id));
|
|
||||||
diesel::joinable!(heirs -> users (user_id));
|
|
||||||
diesel::joinable!(session_keys -> users (user_id));
|
|
||||||
diesel::joinable!(users -> limits (limits));
|
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
|
||||||
date_entries,
|
|
||||||
entries,
|
|
||||||
heirs,
|
|
||||||
limits,
|
|
||||||
location_entries,
|
|
||||||
music_entries,
|
|
||||||
session_keys,
|
|
||||||
users,
|
|
||||||
);
|
|
|
@ -1,121 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
use std::{env, str::FromStr};
|
|
||||||
|
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
|
|
||||||
|
|
||||||
const REQUIRED_ENV_VARIABLES: [&str; 4] = [
|
|
||||||
"IDENTITY_API_JWT_SECRET",
|
|
||||||
"IDENTITY_API_ASSET_API_ENDPOINT",
|
|
||||||
"IDENTITY_API_JWT_ALG",
|
|
||||||
"DATABASE_URL",
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LoadEnvError {
|
|
||||||
DotenvyError(dotenvy::Error),
|
|
||||||
MissingVariable(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_env() -> Result<(), LoadEnvError> {
|
|
||||||
dotenvy::dotenv().map_err(LoadEnvError::DotenvyError)?;
|
|
||||||
|
|
||||||
for key in REQUIRED_ENV_VARIABLES {
|
|
||||||
if env::var(key).is_err() {
|
|
||||||
return Err(LoadEnvError::MissingVariable(key.into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn landing_message() -> &'static str {
|
|
||||||
static IDENTITY_API_LANDING_MESSAGE: OnceLock<String> = OnceLock::new();
|
|
||||||
IDENTITY_API_LANDING_MESSAGE.get_or_init(|| {
|
|
||||||
env::var("IDENTITY_API_LANDING_MESSAGE").unwrap_or(format!(
|
|
||||||
"{} v{}",
|
|
||||||
env!("CARGO_PKG_NAME"),
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_api_m2m_refresh_interval() -> &'static Duration {
|
|
||||||
static IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL: OnceLock<Duration> = OnceLock::new();
|
|
||||||
IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL.get_or_init(|| {
|
|
||||||
let millis: u64 = env::var("IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_ms")
|
|
||||||
.map(|v|
|
|
||||||
v
|
|
||||||
.parse()
|
|
||||||
.expect("invalid environment variable value: `IDENTITY_API_ASSET_API_M2M_REFRESH_INTERVAL_ms`")
|
|
||||||
)
|
|
||||||
.unwrap_or(60 * 1000);
|
|
||||||
|
|
||||||
Duration::from_millis(millis)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn listen_port() -> &'static u16 {
|
|
||||||
static IDENTITY_API_LISTEN_PORT: OnceLock<u16> = OnceLock::new();
|
|
||||||
IDENTITY_API_LISTEN_PORT.get_or_init(|| {
|
|
||||||
env::var("IDENTITY_API_LISTEN_PORT")
|
|
||||||
.map(|v| {
|
|
||||||
v.parse()
|
|
||||||
.expect("invalid environment variable value: `IDENTITY_API_LISTEN_PORT`")
|
|
||||||
})
|
|
||||||
.unwrap_or(3000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn jwt_secret() -> &'static (EncodingKey, DecodingKey) {
|
|
||||||
static IDENTITY_API_JWT_SECRET: OnceLock<(EncodingKey, DecodingKey)> = OnceLock::new();
|
|
||||||
IDENTITY_API_JWT_SECRET.get_or_init(|| {
|
|
||||||
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()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn jwt_alg() -> &'static Algorithm {
|
|
||||||
static IDENTITY_API_JWT_ALG: OnceLock<Algorithm> = OnceLock::new();
|
|
||||||
IDENTITY_API_JWT_ALG.get_or_init(|| {
|
|
||||||
let algo = env::var("IDENTITY_API_JWT_ALG")
|
|
||||||
.expect("environment variables were not loaded correctly");
|
|
||||||
Algorithm::from_str(&algo).expect("invalid JWT algorithm")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asset_api_endpoint() -> &'static str {
|
|
||||||
static IDENTITY_API_ASSET_API_ENDPOINT: OnceLock<String> = OnceLock::new();
|
|
||||||
IDENTITY_API_ASSET_API_ENDPOINT.get_or_init(|| {
|
|
||||||
env::var("IDENTITY_API_ASSET_API_ENDPOINT")
|
|
||||||
.expect("environment variables were not loaded correctly")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn database_url() -> &'static str {
|
|
||||||
static DATABASE_URL: OnceLock<String> = OnceLock::new();
|
|
||||||
DATABASE_URL.get_or_init(|| {
|
|
||||||
env::var("DATABASE_URL").expect("environment variables were not loaded correctly")
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,220 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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<Self, Self::Error> {
|
|
||||||
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<HttpEntryLocation> },
|
|
||||||
#[serde(rename = "date")]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
Date { referenced_date: String },
|
|
||||||
#[serde(rename = "song")]
|
|
||||||
Song {
|
|
||||||
artist: String,
|
|
||||||
title: String,
|
|
||||||
links: Vec<String>,
|
|
||||||
id: Vec<UniversalId>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "album")]
|
|
||||||
Album {
|
|
||||||
artist: String,
|
|
||||||
title: String,
|
|
||||||
links: Vec<String>,
|
|
||||||
id: Vec<UniversalId>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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<MusicEntry>,
|
|
||||||
Option<LocationEntry>,
|
|
||||||
Option<DateEntry>,
|
|
||||||
),
|
|
||||||
) -> Option<Self> {
|
|
||||||
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<String>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub creation_date: String,
|
|
||||||
pub assets: Vec<String>,
|
|
||||||
pub feelings: Vec<HttpEntryFeeling>,
|
|
||||||
pub base: HttpEntryBase,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<FullDatabaseEntry> for HttpEntry {
|
|
||||||
type Error = &'static str;
|
|
||||||
|
|
||||||
fn try_from(
|
|
||||||
(entry, music_entry, location_entry, date_entry): FullDatabaseEntry,
|
|
||||||
) -> Result<Self, Self::Error> {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,80 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use crate::auth::JwtUser;
|
|
||||||
use crate::database::{actions, models::User};
|
|
||||||
use crate::AppState;
|
|
||||||
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]
|
|
||||||
impl<S> FromRequestParts<S> for ExtractJwtUser
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = (StatusCode, &'static str);
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
if let Some(authorization) = parts.headers.get(AUTHORIZATION) {
|
|
||||||
if let Ok(authorization) = authorization.to_str() {
|
|
||||||
let token = authorization.replacen("Bearer ", "", 1);
|
|
||||||
match crate::auth::decode_jwt(&token) {
|
|
||||||
Ok(claims) => Ok(Self(claims.claims)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!("token couldn't be decoded: {:?}", err);
|
|
||||||
Err((StatusCode::UNAUTHORIZED, "Invalid token"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("invalid authorization header: {:?}", authorization);
|
|
||||||
Err((StatusCode::BAD_REQUEST, "Invalid `AUTHORIZATION` header"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("missing authorization header");
|
|
||||||
Err((StatusCode::BAD_REQUEST, "Missing `AUTHORIZATION` header"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ExtractUser(pub User);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl FromRequestParts<AppState> for ExtractUser {
|
|
||||||
type Rejection = (StatusCode, &'static str);
|
|
||||||
|
|
||||||
async fn from_request_parts(
|
|
||||||
parts: &mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<Self, Self::Rejection> {
|
|
||||||
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(user) = actions::user(&jwt_user.uid, &mut conn) {
|
|
||||||
Ok(Self(user))
|
|
||||||
} else {
|
|
||||||
error!("JWT user does not exist in database");
|
|
||||||
Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
use crate::AppState;
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, StatusCode},
|
|
||||||
};
|
|
||||||
use diesel::{
|
|
||||||
r2d2::{ConnectionManager, PooledConnection},
|
|
||||||
SqliteConnection,
|
|
||||||
};
|
|
||||||
use tracing::error;
|
|
||||||
|
|
||||||
pub struct Database(pub PooledConnection<ConnectionManager<SqliteConnection>>);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl FromRequestParts<AppState> for Database {
|
|
||||||
type Rejection = (StatusCode, &'static str);
|
|
||||||
|
|
||||||
async fn from_request_parts(
|
|
||||||
_parts: &mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<Self, Self::Rejection> {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
pub mod database;
|
|
|
@ -1,19 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
mod entry;
|
|
||||||
pub mod extractors;
|
|
||||||
pub mod routes;
|
|
|
@ -1,345 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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 uuid::Uuid;
|
|
||||||
|
|
||||||
pub fn auth_router() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/auth/account", get(account))
|
|
||||||
.route("/auth/register", post(register))
|
|
||||||
.route("/auth/login", post(login))
|
|
||||||
.route("/auth/genkey", get(genkey))
|
|
||||||
.route("/auth/heirs", get(list_heirs))
|
|
||||||
.route("/auth/heirs", put(insert_heir))
|
|
||||||
.route("/auth/heirs", delete(delete_heir))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct AccountResponse {
|
|
||||||
id: String,
|
|
||||||
created_at: NaiveDateTime,
|
|
||||||
last_connected_at: NaiveDateTime,
|
|
||||||
email: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn account(ExtractUser(user): ExtractUser) -> Result<Json<AccountResponse>, StatusCode> {
|
|
||||||
Ok(Json(AccountResponse {
|
|
||||||
id: user.id,
|
|
||||||
created_at: user.created_at,
|
|
||||||
last_connected_at: user.last_connected_at,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct GenkeyResponse {
|
|
||||||
session_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn genkey(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
ExtractJwtUser(user): ExtractJwtUser,
|
|
||||||
) -> Result<Json<GenkeyResponse>, StatusCode> {
|
|
||||||
use crate::database::schema::session_keys::dsl::*;
|
|
||||||
|
|
||||||
let session_key = Uuid::new_v4().to_string();
|
|
||||||
diesel::insert_into(session_keys)
|
|
||||||
.values((user_id.eq(&user.uid), key.eq(&session_key)))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to insert into session_keys {}, error: {err:?}",
|
|
||||||
user.uid
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(GenkeyResponse { session_key }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct LoginRequest {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct LoginResponse {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
Json(req): Json<LoginRequest>,
|
|
||||||
) -> Result<Json<LoginResponse>, 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");
|
|
||||||
Argon2::default()
|
|
||||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
|
||||||
.map_err(|_err| {
|
|
||||||
info!("failed login attempt, invalid password: {}", &req.email);
|
|
||||||
StatusCode::UNAUTHORIZED
|
|
||||||
})?;
|
|
||||||
|
|
||||||
info!("valid login attempt: {}", req.email);
|
|
||||||
let token = encode_jwt(&JwtUser {
|
|
||||||
uid: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
exp: expiration_time(),
|
|
||||||
})
|
|
||||||
.map_err(|err| {
|
|
||||||
error!("token couldn't be encoded: {:?}", err);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(LoginResponse { token }))
|
|
||||||
} else {
|
|
||||||
info!("failed login attempt, email does not exist: {}", &req.email);
|
|
||||||
Err(StatusCode::UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct RegisterRequest {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct RegisterResponse {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn register(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
Json(req): Json<RegisterRequest>,
|
|
||||||
) -> Result<Json<RegisterResponse>, StatusCode> {
|
|
||||||
use crate::database::schema::limits::dsl as limits;
|
|
||||||
use crate::database::schema::users::dsl as users;
|
|
||||||
|
|
||||||
let user = actions::user_by_email(&req.email, &mut conn).map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to retrieve potential existing user from database: {}, error: {err:?}",
|
|
||||||
&req.email
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if user.is_some() {
|
|
||||||
info!("tried to register existing user: {}", &req.email);
|
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
let limit_id = Uuid::new_v4().to_string();
|
|
||||||
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)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to insert into limits: {}, error: {err:?}",
|
|
||||||
&req.email
|
|
||||||
);
|
|
||||||
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)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!("failed to hash password: {err:?}");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let user_id = Uuid::new_v4().to_string();
|
|
||||||
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),
|
|
||||||
// FIXME(sofia): Implement diesel::Expression for List
|
|
||||||
users::assets.eq("[]"),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!("failed to insert into users: {}, error: {err:?}", req.email);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let token = crate::auth::encode_jwt(&JwtUser {
|
|
||||||
uid: user_id,
|
|
||||||
email: req.email,
|
|
||||||
name: req.name,
|
|
||||||
exp: expiration_time(),
|
|
||||||
})
|
|
||||||
.map_err(|err| {
|
|
||||||
error!("token couldn't be encoded: {:?}", err);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(RegisterResponse { token }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct HttpHeir {
|
|
||||||
id: String,
|
|
||||||
contact_method: String,
|
|
||||||
name: String,
|
|
||||||
value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<crate::database::models::Heir> for HttpHeir {
|
|
||||||
fn from(value: crate::database::models::Heir) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id,
|
|
||||||
// Only e-mail is implemented right now
|
|
||||||
contact_method: "email".into(),
|
|
||||||
name: value.name,
|
|
||||||
value: value.email.unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_heirs(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
ExtractJwtUser(user): ExtractJwtUser,
|
|
||||||
) -> Result<Json<Vec<HttpHeir>>, StatusCode> {
|
|
||||||
let heirs = actions::list_heirs(&user.uid, &mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to obtain heirs: {}, error: {err:?}",
|
|
||||||
user.uid
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(heirs.into_iter().map(HttpHeir::from).collect()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct InsertHeirRequest {
|
|
||||||
contact_method: String,
|
|
||||||
name: String,
|
|
||||||
value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_heir(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
ExtractJwtUser(user): ExtractJwtUser,
|
|
||||||
Json(req): Json<InsertHeirRequest>,
|
|
||||||
) -> Result<Json<Vec<HttpHeir>>, StatusCode> {
|
|
||||||
use crate::database::schema::heirs::dsl::*;
|
|
||||||
|
|
||||||
let heir_id = Uuid::new_v4().to_string();
|
|
||||||
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)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to insert into heirs: {}, error: {err:?}",
|
|
||||||
user.uid
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let heirs_list = actions::list_heirs(&user.uid, &mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to obtain heirs: {}, error: {err:?}",
|
|
||||||
user.uid
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct DeleteHeirRequest {
|
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_heir(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
ExtractJwtUser(user): ExtractJwtUser,
|
|
||||||
Json(req): Json<DeleteHeirRequest>,
|
|
||||||
) -> Result<Json<Vec<HttpHeir>>, StatusCode> {
|
|
||||||
use crate::database::schema::heirs::dsl::*;
|
|
||||||
|
|
||||||
diesel::delete(heirs.filter(id.eq(&req.id))).execute(&mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to delete from heirs: {}, heir_id: {}, error: {err:?}",
|
|
||||||
user.uid,
|
|
||||||
req.id,
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let heirs_list = actions::list_heirs(&user.uid, &mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to obtain heirs: {}, error: {err:?}",
|
|
||||||
user.uid,
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(heirs_list.into_iter().map(HttpHeir::from).collect()))
|
|
||||||
}
|
|
|
@ -1,295 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
database::{
|
|
||||||
actions,
|
|
||||||
list::List,
|
|
||||||
models::{DateEntry, LocationEntry, MusicEntry},
|
|
||||||
},
|
|
||||||
http::{
|
|
||||||
entry::*,
|
|
||||||
extractors::{
|
|
||||||
auth::{ExtractJwtUser, ExtractUser},
|
|
||||||
database::Database,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::Query,
|
|
||||||
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<AppState> {
|
|
||||||
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<DeleteEntryQuery>,
|
|
||||||
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<ListEntriesQuery>,
|
|
||||||
ExtractUser(user): ExtractUser,
|
|
||||||
) -> Result<Json<Vec<HttpEntry>>, StatusCode> {
|
|
||||||
let entries = actions::list_entries_recursive(&user.id, query.offset, query.limit, &mut conn)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!("failed to obtain entries {}: {err:?}", user.id);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(
|
|
||||||
entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|v| HttpEntry::try_from(v).ok())
|
|
||||||
.collect(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct InsertEntryBody {
|
|
||||||
entry: HttpEntry,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_entry(
|
|
||||||
Database(mut conn): Database,
|
|
||||||
ExtractUser(user): ExtractUser,
|
|
||||||
Json(entry): Json<InsertEntryBody>,
|
|
||||||
) -> Result<(), StatusCode> {
|
|
||||||
let mut music_entry: Option<MusicEntry> = None;
|
|
||||||
let mut location_entry: Option<LocationEntry> = None;
|
|
||||||
let mut date_entry: Option<DateEntry> = 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")
|
|
||||||
.map_err(|err| {
|
|
||||||
warn!(
|
|
||||||
"invalid date in request for inserting entry: {}, err: {err:?}",
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
StatusCode::BAD_REQUEST
|
|
||||||
})?;
|
|
||||||
|
|
||||||
date_entry = Some(DateEntry {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
referenced_date: NaiveDateTime::new(
|
|
||||||
naive_date,
|
|
||||||
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;
|
|
||||||
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)
|
|
||||||
.map_err(|err| {
|
|
||||||
error!(
|
|
||||||
"failed to insert into entries: {}, error: {err:?}",
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
pub mod entry;
|
|
|
@ -1,133 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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, entry::entry_router};
|
|
||||||
use r2d2::Pool;
|
|
||||||
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;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AppState {
|
|
||||||
pool: Pool<ConnectionManager<SqliteConnection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
let _ = env::load_env().inspect_err(|e| match e {
|
|
||||||
LoadEnvError::DotenvyError(error) => panic!("failed to load .env file: {:?}", error),
|
|
||||||
LoadEnvError::MissingVariable(variable) => {
|
|
||||||
panic!("missing environment variable: `{variable}`")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
|
||||||
// axum logs rejections from built-in extractors with the `axum::rejection`
|
|
||||||
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
|
|
||||||
format!(
|
|
||||||
"{}=debug,tower_http=debug,axum::rejection=trace",
|
|
||||||
env!("CARGO_CRATE_NAME")
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.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"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/", get(landing))
|
|
||||||
.merge(auth_router())
|
|
||||||
.merge(entry_router())
|
|
||||||
.with_state(state)
|
|
||||||
.layer(
|
|
||||||
TraceLayer::new_for_http()
|
|
||||||
.make_span_with(|request: &Request<_>| {
|
|
||||||
let matched_path = request
|
|
||||||
.extensions()
|
|
||||||
.get::<MatchedPath>()
|
|
||||||
.map(MatchedPath::as_str);
|
|
||||||
|
|
||||||
info_span!(
|
|
||||||
"http_request",
|
|
||||||
method = ?request.method(),
|
|
||||||
matched_path,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.on_response(|response: &Response, _latency: Duration, _span: &Span| {
|
|
||||||
if response.status().is_client_error() {
|
|
||||||
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(),);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.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()))
|
|
||||||
.await
|
|
||||||
.expect("failed to bind");
|
|
||||||
|
|
||||||
println!("listening on http://{}", listener.local_addr().unwrap());
|
|
||||||
axum::serve(listener, app)
|
|
||||||
.await
|
|
||||||
.expect("failed to serve app");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn landing() -> &'static str {
|
|
||||||
env::landing_message()
|
|
||||||
}
|
|
|
@ -215,7 +215,7 @@ export async function startDatabase() {
|
||||||
let musicDetails = (
|
let musicDetails = (
|
||||||
await database.select().from(musicEntries).where(eq(musicEntries.id, entry.musicEntry))
|
await database.select().from(musicEntries).where(eq(musicEntries.id, entry.musicEntry))
|
||||||
)[0];
|
)[0];
|
||||||
(musicDetails["links"] as any) = fromDBList(musicDetails.links);
|
(musicDetails["link"] as any) = fromDBList(musicDetails.links);
|
||||||
(musicDetails["id"] as any) = fromDBList(musicDetails.universalIDs);
|
(musicDetails["id"] as any) = fromDBList(musicDetails.universalIDs);
|
||||||
|
|
||||||
musicDetails["links"] = undefined;
|
musicDetails["links"] = undefined;
|
||||||
|
|
|
@ -102,7 +102,7 @@ export default function register(app: AppInterface, auth: AuthInterface, databas
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.removeHeir(request.body.id);
|
await database.removeHeir(request.body);
|
||||||
|
|
||||||
return (await database.listHeirs(payload.uid))
|
return (await database.listHeirs(payload.uid))
|
||||||
.map((v) => (v["contactMethod"] = "email"))
|
.map((v) => (v["contactMethod"] = "email"))
|
||||||
|
@ -111,9 +111,9 @@ export default function register(app: AppInterface, auth: AuthInterface, databas
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
headers: { $ref: "schema://identity/authorization" },
|
headers: { $ref: "schema://identity/authorization" },
|
||||||
body: Type.Object({
|
body: {
|
||||||
id: Type.String(),
|
type: "string",
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,7 +53,7 @@ const PutEntryBody = Type.Object({
|
||||||
kind: Type.String(),
|
kind: Type.String(),
|
||||||
artist: Type.String(),
|
artist: Type.String(),
|
||||||
title: Type.String(),
|
title: Type.String(),
|
||||||
links: Type.Array(Type.String()),
|
link: Type.Array(Type.String()),
|
||||||
id: Type.Array(
|
id: Type.Array(
|
||||||
Type.Object({
|
Type.Object({
|
||||||
provider: Type.String(),
|
provider: Type.String(),
|
||||||
|
@ -111,7 +111,7 @@ export default function registerRoutes(app: AppInterface, auth: AuthInterface, d
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
title: entry.base.title,
|
title: entry.base.title,
|
||||||
artist: entry.base.artist,
|
artist: entry.base.artist,
|
||||||
links: toDBList(entry.base.links),
|
links: toDBList(entry.base.link),
|
||||||
universalIDs: toDBList(entry.base.id),
|
universalIDs: toDBList(entry.base.id),
|
||||||
};
|
};
|
||||||
} else if (entry.base.kind === "environment" && "location" in entry.base) {
|
} else if (entry.base.kind === "environment" && "location" in entry.base) {
|
||||||
|
|
|
@ -145,10 +145,7 @@ export async function removeHeir(credentials: Credentials, heirID: string): Prom
|
||||||
return await asJson(
|
return await asJson(
|
||||||
sendRequest('/auth/heirs', credentials, {
|
sendRequest('/auth/heirs', credentials, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
body: heirID
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: heirID })
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,7 +115,7 @@ export type SongEntry = {
|
||||||
kind: 'song';
|
kind: 'song';
|
||||||
artist: string;
|
artist: string;
|
||||||
title: string;
|
title: string;
|
||||||
links: string[];
|
link: string[];
|
||||||
id: UniversalID[];
|
id: UniversalID[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -123,7 +123,7 @@ export type AlbumEntry = {
|
||||||
kind: 'album';
|
kind: 'album';
|
||||||
artist: string;
|
artist: string;
|
||||||
title: string;
|
title: string;
|
||||||
links: string[];
|
link: string[];
|
||||||
id: UniversalID[];
|
id: UniversalID[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -121,8 +121,8 @@
|
||||||
>
|
>
|
||||||
<div slot="contracted">
|
<div slot="contracted">
|
||||||
{#if entry.base.kind === 'song' || entry.base.kind === 'album'}
|
{#if entry.base.kind === 'song' || entry.base.kind === 'album'}
|
||||||
{#if entry.base.links[0] != null}
|
{#if entry.base.link[0] != null}
|
||||||
<ExternalLink href={entry.base.links[0]}>
|
<ExternalLink href={entry.base.link[0]}>
|
||||||
{entry.base.artist} ‐ {entry.base.title}
|
{entry.base.artist} ‐ {entry.base.title}
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -163,8 +163,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if entry.base.kind === 'song' || entry.base.kind === 'album'}
|
{#if entry.base.kind === 'song' || entry.base.kind === 'album'}
|
||||||
{#if entry.base.links[0] != null}
|
{#if entry.base.link[0] != null}
|
||||||
<ExternalLink href={entry.base.links[0]}>
|
<ExternalLink href={entry.base.link[0]}>
|
||||||
{entry.base.artist} ‐ {entry.base.title}
|
{entry.base.artist} ‐ {entry.base.title}
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
kind: values.kind,
|
kind: values.kind,
|
||||||
artist: values.artist,
|
artist: values.artist,
|
||||||
title: values.musicTitle,
|
title: values.musicTitle,
|
||||||
links: [values.spotify, values.yt, values.otherProvider].filter(
|
link: [values.spotify, values.yt, values.otherProvider].filter(
|
||||||
(v) => v != null && v.length > 0
|
(v) => v != null && v.length > 0
|
||||||
),
|
),
|
||||||
// FIXME: Infer Universal IDs (Spotify URL, etc)
|
// FIXME: Infer Universal IDs (Spotify URL, etc)
|
||||||
|
|
Loading…
Reference in a new issue