commit f2d2e1a8a4c7e3af0a50e7747fee7ac00ad4c56c Author: Elias Projahn Date: Sat Jan 30 14:20:21 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93fdb52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.env +/Cargo.lock +/target \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..393f2f0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wolfgang" +version = "0.1.0" +edition = "2018" + +[dependencies] +actix-web = "3.2.0" +actix-web-httpauth = "0.5.0" +anyhow = "1.0.34" +derive_more = "0.99.11" +diesel = { version = "1.4.4", features = ["postgres", "r2d2"] } +diesel_migrations = "1.4.0" +dotenv = "0.15.0" +env_logger = "0.8.1" +jsonwebtoken = "7.2.0" +r2d2 = "0.8.9" +rand = "0.7.3" +serde = { version = "1.0.117", features = ["derive"] } +serde_json = "1.0.59" +sodiumoxide = "0.2.6" diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf202b3 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Wolfgang + +This is a server for hosting metadata on classical music. + +## Running + +Wolfgang should reside behind a reverse proxy (e.g. Nginx) that is set up to +only use TLS encrypted connections. You will need a running +[PostgreSQL](https://www.postgresql.org/) service. To set up the database (and +migrate to future versions) use the Diesel command line utility from within +the source code repository. This utility and Wolfgang itself use the +environment variable `WOLFGANG_DATABASE_URL` to find the database. A nice way +to set it up is to use a file called `.env` within the toplevel directory of +the repository. + +```bash +# Install the Diesel command line utility: +cargo install diesel_cli --no-default-features --features postgres + +# Configure the database URL (replace username and table): +echo "WOLFGANG_DATABASE_URL=\"postgres://username@localhost/table\"" >> .env + +# Run migrations: +~/.cargo/bin/diesel migration run + +# Set a secret that will be used to sign access tokens: +echo "WOLFGANG_SECRET=\"$(openssl rand -base64 64)\"" >> .env +``` + +## Hacking + +Wolfgang is written in [Rust](https://www.rust-lang.org) using the +[Actix Web](https://actix.rs/) framework for serving requests and +[Diesel](https://diesel.rs/) for database access. The linked websites should +provide you with the necessary information to get started. + +## License + +Wolfgang is free and open source 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. + +Wolfgang 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/. diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..d3e7db2 --- /dev/null +++ b/diesel.toml @@ -0,0 +1,2 @@ +[print_schema] +file = "src/database/schema.rs" \ No newline at end of file diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/2020-11-09-153819_initial_schema/down.sql b/migrations/2020-11-09-153819_initial_schema/down.sql new file mode 100644 index 0000000..8c6540d --- /dev/null +++ b/migrations/2020-11-09-153819_initial_schema/down.sql @@ -0,0 +1,19 @@ +DROP TABLE performances; + +DROP TABLE recordings; + +DROP TABLE ensembles; + +DROP TABLE work_sections; + +DROP TABLE work_parts; + +DROP TABLE instrumentations; + +DROP TABLE works; + +DROP TABLE instruments; + +DROP TABLE persons; + +DROP TABLE users; \ No newline at end of file diff --git a/migrations/2020-11-09-153819_initial_schema/up.sql b/migrations/2020-11-09-153819_initial_schema/up.sql new file mode 100644 index 0000000..3abb11b --- /dev/null +++ b/migrations/2020-11-09-153819_initial_schema/up.sql @@ -0,0 +1,70 @@ +CREATE TABLE users ( + username TEXT NOT NULL PRIMARY KEY, + password_hash TEXT NOT NULL, + email TEXT, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_editor BOOLEAN NOT NULL DEFAULT FALSE, + is_banned BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE persons ( + id TEXT NOT NULL PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE instruments ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE works ( + id TEXT NOT NULL PRIMARY KEY, + composer TEXT NOT NULL REFERENCES persons(id), + title TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE instrumentations ( + id BIGINT NOT NULL PRIMARY KEY, + work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + instrument TEXT NOT NULL REFERENCES instruments(id) ON DELETE CASCADE +); + +CREATE TABLE work_parts ( + id BIGINT NOT NULL PRIMARY KEY, + work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + part_index BIGINT NOT NULL, + title TEXT NOT NULL, + composer TEXT REFERENCES persons(id) +); + +CREATE TABLE work_sections ( + id BIGINT NOT NULL PRIMARY KEY, + work TEXT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + title TEXT NOT NULL, + before_index BIGINT NOT NULL +); + +CREATE TABLE ensembles ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE recordings ( + id TEXT NOT NULL PRIMARY KEY, + work TEXT NOT NULL REFERENCES works(id), + comment TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE performances ( + id BIGINT NOT NULL PRIMARY KEY, + recording TEXT NOT NULL REFERENCES recordings(id) ON DELETE CASCADE, + person TEXT REFERENCES persons(id), + ensemble TEXT REFERENCES ensembles(id), + role TEXT REFERENCES instruments(id) +); \ No newline at end of file diff --git a/src/database/ensembles.rs b/src/database/ensembles.rs new file mode 100644 index 0000000..32eb94c --- /dev/null +++ b/src/database/ensembles.rs @@ -0,0 +1,99 @@ +use super::schema::ensembles; +use super::{DbConn, User}; +use crate::error::ServerError; +use anyhow::{Error, Result}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +/// A ensemble as represented within the API. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Ensemble { + pub id: String, + pub name: String, +} + +/// A ensemble as represented in the database. +#[derive(Insertable, Queryable, AsChangeset, Debug, Clone)] +#[table_name = "ensembles"] +struct EnsembleRow { + pub id: String, + pub name: String, + pub created_by: String, +} + +impl From for Ensemble { + fn from(row: EnsembleRow) -> Ensemble { + Ensemble { + id: row.id, + name: row.name, + } + } +} + +/// Update an existing ensemble or insert a new one. This will only work, if the provided user is +/// allowed to do that. +pub fn update_ensemble(conn: &DbConn, ensemble: &Ensemble, user: &User) -> Result<()> { + let old_row = get_ensemble_row(conn, &ensemble.id)?; + + let allowed = match old_row { + Some(row) => user.may_edit(&row.created_by), + None => user.may_create(), + }; + + if allowed { + let new_row = EnsembleRow { + id: ensemble.id.clone(), + name: ensemble.name.clone(), + created_by: user.username.clone(), + }; + + diesel::insert_into(ensembles::table) + .values(&new_row) + .on_conflict(ensembles::id) + .do_update() + .set(&new_row) + .execute(conn)?; + + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get an existing ensemble. +pub fn get_ensemble(conn: &DbConn, id: &str) -> Result> { + let row = get_ensemble_row(conn, id)?; + let ensemble = row.map(|row| row.into()); + + Ok(ensemble) +} + +/// Delete an existing ensemble. This will only work if the provided user is allowed to do that. +pub fn delete_ensemble(conn: &DbConn, id: &str, user: &User) -> Result<()> { + if user.may_delete() { + diesel::delete(ensembles::table.filter(ensembles::id.eq(id))).execute(conn)?; + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get all existing ensembles. +pub fn get_ensembles(conn: &DbConn) -> Result> { + let rows = ensembles::table.load::(conn)?; + let ensembles: Vec = rows.into_iter().map(|row| row.into()).collect(); + + Ok(ensembles) +} + +/// Get a ensemble row if it exists. +fn get_ensemble_row(conn: &DbConn, id: &str) -> Result> { + let row = ensembles::table + .filter(ensembles::id.eq(id)) + .load::(conn)? + .into_iter() + .next(); + + Ok(row) +} diff --git a/src/database/instruments.rs b/src/database/instruments.rs new file mode 100644 index 0000000..3b4a7fe --- /dev/null +++ b/src/database/instruments.rs @@ -0,0 +1,99 @@ +use super::schema::instruments; +use super::{DbConn, User}; +use crate::error::ServerError; +use anyhow::{Error, Result}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +/// A instrument as represented within the API. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Instrument { + pub id: String, + pub name: String, +} + +/// A instrument as represented in the database. +#[derive(Insertable, Queryable, AsChangeset, Debug, Clone)] +#[table_name = "instruments"] +struct InstrumentRow { + pub id: String, + pub name: String, + pub created_by: String, +} + +impl From for Instrument { + fn from(row: InstrumentRow) -> Instrument { + Instrument { + id: row.id, + name: row.name, + } + } +} + +/// Update an existing instrument or insert a new one. This will only work, if the provided user is +/// allowed to do that. +pub fn update_instrument(conn: &DbConn, instrument: &Instrument, user: &User) -> Result<()> { + let old_row = get_instrument_row(conn, &instrument.id)?; + + let allowed = match old_row { + Some(row) => user.may_edit(&row.created_by), + None => user.may_create(), + }; + + if allowed { + let new_row = InstrumentRow { + id: instrument.id.clone(), + name: instrument.name.clone(), + created_by: user.username.clone(), + }; + + diesel::insert_into(instruments::table) + .values(&new_row) + .on_conflict(instruments::id) + .do_update() + .set(&new_row) + .execute(conn)?; + + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get an existing instrument. +pub fn get_instrument(conn: &DbConn, id: &str) -> Result> { + let row = get_instrument_row(conn, id)?; + let instrument = row.map(|row| row.into()); + + Ok(instrument) +} + +/// Delete an existing instrument. This will only work if the provided user is allowed to do that. +pub fn delete_instrument(conn: &DbConn, id: &str, user: &User) -> Result<()> { + if user.may_delete() { + diesel::delete(instruments::table.filter(instruments::id.eq(id))).execute(conn)?; + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get all existing instruments. +pub fn get_instruments(conn: &DbConn) -> Result> { + let rows = instruments::table.load::(conn)?; + let instruments: Vec = rows.into_iter().map(|row| row.into()).collect(); + + Ok(instruments) +} + +/// Get a instrument row if it exists. +fn get_instrument_row(conn: &DbConn, id: &str) -> Result> { + let row = instruments::table + .filter(instruments::id.eq(id)) + .load::(conn)? + .into_iter() + .next(); + + Ok(row) +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..a2b1eb1 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use diesel::r2d2; +use diesel::PgConnection; + +pub mod ensembles; +pub use ensembles::*; + +pub mod instruments; +pub use instruments::*; + +pub mod persons; +pub use persons::*; + +pub mod recordings; +pub use recordings::*; + +pub mod users; +pub use users::*; + +pub mod works; +pub use works::*; + +mod schema; + +// This makes the SQL migration scripts accessible from the code. +embed_migrations!(); + +/// A pool of connections to the database. +pub type DbPool = r2d2::Pool>; + +/// One database connection from the connection pool. +pub type DbConn = r2d2::PooledConnection>; + +/// Create a connection pool for a database. This will look for the database URL in the +/// "WOLFGANG_DATABASE_URL" environment variable and fail, if that is not set. +pub fn connect() -> Result { + let url = std::env::var("WOLFGANG_DATABASE_URL")?; + let manager = r2d2::ConnectionManager::::new(url); + let pool = r2d2::Pool::new(manager)?; + + // Run embedded migrations. + let conn = pool.get()?; + embedded_migrations::run(&conn)?; + + Ok(pool) +} diff --git a/src/database/persons.rs b/src/database/persons.rs new file mode 100644 index 0000000..df2c704 --- /dev/null +++ b/src/database/persons.rs @@ -0,0 +1,103 @@ +use super::schema::persons; +use super::{DbConn, User}; +use crate::error::ServerError; +use anyhow::{Error, Result}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +/// A person as represented within the API. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Person { + pub id: String, + pub first_name: String, + pub last_name: String, +} + +/// A person as represented in the database. +#[derive(Insertable, Queryable, AsChangeset, Debug, Clone)] +#[table_name = "persons"] +struct PersonRow { + pub id: String, + pub first_name: String, + pub last_name: String, + pub created_by: String, +} + +impl From for Person { + fn from(row: PersonRow) -> Person { + Person { + id: row.id, + first_name: row.first_name, + last_name: row.last_name, + } + } +} + +/// Update an existing person or insert a new one. This will only work, if the provided user is +/// allowed to do that. +pub fn update_person(conn: &DbConn, person: &Person, user: &User) -> Result<()> { + let old_row = get_person_row(conn, &person.id)?; + + let allowed = match old_row { + Some(row) => user.may_edit(&row.created_by), + None => user.may_create(), + }; + + if allowed { + let new_row = PersonRow { + id: person.id.clone(), + first_name: person.first_name.clone(), + last_name: person.last_name.clone(), + created_by: user.username.clone(), + }; + + diesel::insert_into(persons::table) + .values(&new_row) + .on_conflict(persons::id) + .do_update() + .set(&new_row) + .execute(conn)?; + + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get an existing person. +pub fn get_person(conn: &DbConn, id: &str) -> Result> { + let row = get_person_row(conn, id)?; + let person = row.map(|row| row.into()); + + Ok(person) +} + +/// Delete an existing person. This will only work if the provided user is allowed to do that. +pub fn delete_person(conn: &DbConn, id: &str, user: &User) -> Result<()> { + if user.may_delete() { + diesel::delete(persons::table.filter(persons::id.eq(id))).execute(conn)?; + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get all existing persons. +pub fn get_persons(conn: &DbConn) -> Result> { + let rows = persons::table.load::(conn)?; + let persons: Vec = rows.into_iter().map(|row| row.into()).collect(); + + Ok(persons) +} + +/// Get a person row if it exists. +fn get_person_row(conn: &DbConn, id: &str) -> Result> { + let row = persons::table + .filter(persons::id.eq(id)) + .load::(conn)? + .into_iter() + .next(); + + Ok(row) +} diff --git a/src/database/recordings.rs b/src/database/recordings.rs new file mode 100644 index 0000000..1452746 --- /dev/null +++ b/src/database/recordings.rs @@ -0,0 +1,255 @@ +use super::schema::{ensembles, performances, persons, recordings}; +use super::{get_ensemble, get_instrument, get_person, get_work}; +use super::{update_ensemble, update_instrument, update_person, update_work}; +use super::{DbConn, Ensemble, Instrument, Person, User, Work}; +use crate::error::ServerError; +use anyhow::{anyhow, Error, Result}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +/// A specific recording of a work. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Recording { + pub id: String, + pub work: Work, + pub comment: String, + pub performances: Vec, +} + +/// How a person or ensemble was involved in a recording. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Performance { + pub person: Option, + pub ensemble: Option, + pub role: Option, +} + +/// Row data for a recording. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "recordings"] +struct RecordingRow { + pub id: String, + pub work: String, + pub comment: String, + pub created_by: String, +} + +/// Row data for a performance. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "performances"] +struct PerformanceRow { + pub id: i64, + pub recording: String, + pub person: Option, + pub ensemble: Option, + pub role: Option, +} + +/// Update an existing recording or insert a new one. This will only work, if the provided user is +/// allowed to do that. +pub fn update_recording(conn: &DbConn, recording: &Recording, user: &User) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let old_row = get_recording_row(conn, &recording.id)?; + + let allowed = match old_row { + Some(row) => user.may_edit(&row.created_by), + None => user.may_create(), + }; + + if allowed { + let id = &recording.id; + + // This will also delete the old performances. + diesel::delete(recordings::table) + .filter(recordings::id.eq(id)) + .execute(conn)?; + + // Add associated items, if they don't already exist. + + if get_work(conn, &recording.work.id)?.is_none() { + update_work(conn, &recording.work, &user)?; + } + + for performance in &recording.performances { + if let Some(person) = &performance.person { + if get_person(conn, &person.id)?.is_none() { + update_person(conn, person, &user)?; + } + } + + if let Some(ensemble) = &performance.ensemble { + if get_ensemble(conn, &ensemble.id)?.is_none() { + update_ensemble(conn, ensemble, &user)?; + } + } + + if let Some(role) = &performance.role { + if get_instrument(conn, &role.id)?.is_none() { + update_instrument(conn, role, &user)?; + } + } + } + + // Add the actual recording. + + let row = RecordingRow { + id: id.clone(), + work: recording.work.id.clone(), + comment: recording.comment.clone(), + created_by: user.username.clone(), + }; + + diesel::insert_into(recordings::table) + .values(row) + .execute(conn)?; + + for performance in &recording.performances { + diesel::insert_into(performances::table) + .values(PerformanceRow { + id: rand::random(), + recording: id.clone(), + person: performance.person.as_ref().map(|person| person.id.clone()), + ensemble: performance + .ensemble + .as_ref() + .map(|ensemble| ensemble.id.clone()), + role: performance.role.as_ref().map(|role| role.id.clone()), + }) + .execute(conn)?; + } + + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } + })?; + + Ok(()) +} + +/// Get an existing recording and all available information from related tables. +pub fn get_recording(conn: &DbConn, id: &str) -> Result> { + let recording = match get_recording_row(conn, id)? { + Some(row) => Some(get_description_for_recording_row(conn, &row)?), + None => None, + }; + + Ok(recording) +} + +/// Get all available information on all recordings where a person is performing. +pub fn get_recordings_for_person(conn: &DbConn, person_id: &str) -> Result> { + let mut recordings: Vec = Vec::new(); + + let rows = recordings::table + .inner_join(performances::table.on(performances::recording.eq(recordings::id))) + .inner_join(persons::table.on(persons::id.nullable().eq(performances::person))) + .filter(persons::id.eq(person_id)) + .select(recordings::table::all_columns()) + .load::(conn)?; + + for row in rows { + recordings.push(get_description_for_recording_row(conn, &row)?); + } + + Ok(recordings) +} + +/// Get all available information on all recordings where an ensemble is performing. +pub fn get_recordings_for_ensemble(conn: &DbConn, ensemble_id: &str) -> Result> { + let mut recordings: Vec = Vec::new(); + + let rows = recordings::table + .inner_join(performances::table.on(performances::recording.eq(recordings::id))) + .inner_join(ensembles::table.on(ensembles::id.nullable().eq(performances::ensemble))) + .filter(ensembles::id.eq(ensemble_id)) + .select(recordings::table::all_columns()) + .load::(conn)?; + + for row in rows { + recordings.push(get_description_for_recording_row(conn, &row)?); + } + + Ok(recordings) +} + +/// Get allavailable information on all recordings of a work. +pub fn get_recordings_for_work(conn: &DbConn, work_id: &str) -> Result> { + let mut recordings: Vec = Vec::new(); + + let rows = recordings::table + .filter(recordings::work.eq(work_id)) + .load::(conn)?; + + for row in rows { + recordings.push(get_description_for_recording_row(conn, &row)?); + } + + Ok(recordings) +} + +/// Delete an existing recording. This will fail if there are still references to this +/// recording from other tables that are not directly part of the recording data. Also, the +/// provided user has to be allowed to delete the recording. +pub fn delete_recording(conn: &DbConn, id: &str, user: &User) -> Result<()> { + if user.may_delete() { + diesel::delete(recordings::table.filter(recordings::id.eq(id))).execute(conn)?; + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get an existing recording row. +fn get_recording_row(conn: &DbConn, id: &str) -> Result> { + Ok(recordings::table + .filter(recordings::id.eq(id)) + .load::(conn)? + .into_iter() + .next()) +} + +/// Retrieve all available information on a recording from related tables. +fn get_description_for_recording_row(conn: &DbConn, row: &RecordingRow) -> Result { + let mut performances: Vec = Vec::new(); + + let performance_rows = performances::table + .filter(performances::recording.eq(&row.id)) + .load::(conn)?; + + for row in performance_rows { + performances.push(Performance { + person: match row.person { + Some(id) => { + Some(get_person(conn, &id)?.ok_or(anyhow!("No person with ID: {}", id))?) + } + None => None, + }, + ensemble: match row.ensemble { + Some(id) => { + Some(get_ensemble(conn, &id)?.ok_or(anyhow!("No ensemble with ID: {}", id))?) + } + None => None, + }, + role: match row.role { + Some(id) => Some( + get_instrument(conn, &id)?.ok_or(anyhow!("No instrument with ID: {}", id))?, + ), + None => None, + }, + }); + } + + let work = get_work(conn, &row.work)?.ok_or(anyhow!("No work with ID: {}", &row.work))?; + + let recording = Recording { + id: row.id.clone(), + work, + comment: row.comment.clone(), + performances, + }; + + Ok(recording) +} diff --git a/src/database/schema.rs b/src/database/schema.rs new file mode 100644 index 0000000..87846e4 --- /dev/null +++ b/src/database/schema.rs @@ -0,0 +1,120 @@ +table! { + ensembles (id) { + id -> Text, + name -> Text, + created_by -> Text, + } +} + +table! { + instrumentations (id) { + id -> Int8, + work -> Text, + instrument -> Text, + } +} + +table! { + instruments (id) { + id -> Text, + name -> Text, + created_by -> Text, + } +} + +table! { + performances (id) { + id -> Int8, + recording -> Text, + person -> Nullable, + ensemble -> Nullable, + role -> Nullable, + } +} + +table! { + persons (id) { + id -> Text, + first_name -> Text, + last_name -> Text, + created_by -> Text, + } +} + +table! { + recordings (id) { + id -> Text, + work -> Text, + comment -> Text, + created_by -> Text, + } +} + +table! { + users (username) { + username -> Text, + password_hash -> Text, + email -> Nullable, + is_admin -> Bool, + is_editor -> Bool, + is_banned -> Bool, + } +} + +table! { + work_parts (id) { + id -> Int8, + work -> Text, + part_index -> Int8, + title -> Text, + composer -> Nullable, + } +} + +table! { + work_sections (id) { + id -> Int8, + work -> Text, + title -> Text, + before_index -> Int8, + } +} + +table! { + works (id) { + id -> Text, + composer -> Text, + title -> Text, + created_by -> Text, + } +} + +joinable!(ensembles -> users (created_by)); +joinable!(instrumentations -> instruments (instrument)); +joinable!(instrumentations -> works (work)); +joinable!(instruments -> users (created_by)); +joinable!(performances -> ensembles (ensemble)); +joinable!(performances -> instruments (role)); +joinable!(performances -> persons (person)); +joinable!(performances -> recordings (recording)); +joinable!(persons -> users (created_by)); +joinable!(recordings -> users (created_by)); +joinable!(recordings -> works (work)); +joinable!(work_parts -> persons (composer)); +joinable!(work_parts -> works (work)); +joinable!(work_sections -> works (work)); +joinable!(works -> persons (composer)); +joinable!(works -> users (created_by)); + +allow_tables_to_appear_in_same_query!( + ensembles, + instrumentations, + instruments, + performances, + persons, + recordings, + users, + work_parts, + work_sections, + works, +); diff --git a/src/database/users.rs b/src/database/users.rs new file mode 100644 index 0000000..14c874b --- /dev/null +++ b/src/database/users.rs @@ -0,0 +1,78 @@ +use super::schema::users; +use super::DbConn; +use anyhow::Result; +use diesel::prelude::*; +use serde::Deserialize; + +/// A user that can be authenticated to use the API. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct User { + pub username: String, + pub password_hash: String, + pub email: Option, + pub is_admin: bool, + pub is_editor: bool, + pub is_banned: bool, +} + +impl User { + /// Check whether the user is allowed to create a new item. + pub fn may_create(&self) -> bool { + !self.is_banned + } + + /// Check whether the user is allowed to edit an item created by him or somebody else. + pub fn may_edit(&self, creator: &str) -> bool { + !self.is_banned && (self.username == creator || self.is_editor) + } + + /// Check whether the user is allowed to delete an item. + pub fn may_delete(&self) -> bool { + !self.is_banned && self.is_editor + } +} + +/// A structure representing data on a user. +#[derive(AsChangeset, Deserialize, Debug, Clone)] +#[table_name = "users"] +#[serde(rename_all = "camelCase")] +pub struct UserInsertion { + pub password_hash: String, + pub email: Option, +} + +/// Insert a new user. +pub fn insert_user(conn: &DbConn, username: &str, data: &UserInsertion) -> Result<()> { + let user = User { + username: username.to_string(), + password_hash: data.password_hash.clone(), + email: data.email.clone(), + is_admin: false, + is_editor: false, + is_banned: false, + }; + diesel::insert_into(users::table) + .values(user) + .execute(conn)?; + + Ok(()) +} + +/// Update an existing user. +pub fn update_user(conn: &DbConn, username: &str, data: &UserInsertion) -> Result<()> { + diesel::update(users::table) + .filter(users::username.eq(username)) + .set(data) + .execute(conn)?; + + Ok(()) +} + +/// Get an existing user. +pub fn get_user(conn: &DbConn, username: &str) -> Result> { + Ok(users::table + .filter(users::username.eq(username)) + .load::(conn)? + .first() + .cloned()) +} diff --git a/src/database/works.rs b/src/database/works.rs new file mode 100644 index 0000000..20975b8 --- /dev/null +++ b/src/database/works.rs @@ -0,0 +1,278 @@ +use super::schema::{instrumentations, work_parts, work_sections, works}; +use super::{get_instrument, get_person, update_instrument, update_person}; +use super::{DbConn, Instrument, Person, User}; +use crate::error::ServerError; +use anyhow::{anyhow, Error, Result}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; + +/// A specific work by a composer. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Work { + pub id: String, + pub title: String, + pub composer: Person, + pub instruments: Vec, + pub parts: Vec, + pub sections: Vec, +} + +/// A playable part of a work. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkPart { + pub title: String, + pub composer: Option, +} + +/// A heading within the work structure. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkSection { + pub title: String, + pub before_index: i64, +} + +/// Table data for a work. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "works"] +struct WorkRow { + pub id: String, + pub composer: String, + pub title: String, + pub created_by: String, +} + +/// Table data for an instrumentation. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "instrumentations"] +struct InstrumentationRow { + pub id: i64, + pub work: String, + pub instrument: String, +} + +/// Table data for a work part. +#[derive(Insertable, Queryable, Debug, Clone)] +#[table_name = "work_parts"] +struct WorkPartRow { + pub id: i64, + pub work: String, + pub part_index: i64, + pub title: String, + pub composer: Option, +} + +/// Table data for a work section. +#[table_name = "work_sections"] +#[derive(Insertable, Queryable, Debug, Clone)] +struct WorkSectionRow { + pub id: i64, + pub work: String, + pub title: String, + pub before_index: i64, +} + +/// Update an existing work or insert a new one. This will only succeed, if the user is allowed to +/// do that. +pub fn update_work(conn: &DbConn, work: &Work, user: &User) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let old_row = get_work_row(conn, &work.id)?; + + let allowed = match old_row { + Some(row) => user.may_edit(&row.created_by), + None => user.may_create(), + }; + + if allowed { + let id = &work.id; + + // This will also delete rows from associated tables. + diesel::delete(works::table) + .filter(works::id.eq(id)) + .execute(conn)?; + + // Add associated items, if they don't already exist. + + if get_person(conn, &work.composer.id)?.is_none() { + update_person(conn, &work.composer, &user)?; + } + + for instrument in &work.instruments { + if get_instrument(conn, &instrument.id)?.is_none() { + update_instrument(conn, instrument, &user)?; + } + } + + for part in &work.parts { + if let Some(person) = &part.composer { + if get_person(conn, &person.id)?.is_none() { + update_person(conn, person, &user)?; + } + } + } + + // Add the actual work. + + let row = WorkRow { + id: id.clone(), + composer: work.composer.id.clone(), + title: work.title.clone(), + created_by: user.username.clone(), + }; + + diesel::insert_into(works::table) + .values(row) + .execute(conn)?; + + for instrument in &work.instruments { + diesel::insert_into(instrumentations::table) + .values(InstrumentationRow { + id: rand::random(), + work: id.clone(), + instrument: instrument.id.clone(), + }) + .execute(conn)?; + } + + for (index, part) in work.parts.iter().enumerate() { + let row = WorkPartRow { + id: rand::random(), + work: id.clone(), + part_index: index.try_into()?, + title: part.title.clone(), + composer: part.composer.as_ref().map(|person| person.id.clone()), + }; + + diesel::insert_into(work_parts::table) + .values(row) + .execute(conn)?; + } + + for section in &work.sections { + let row = WorkSectionRow { + id: rand::random(), + work: id.clone(), + title: section.title.clone(), + before_index: section.before_index, + }; + + diesel::insert_into(work_sections::table) + .values(row) + .execute(conn)?; + } + + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } + })?; + + Ok(()) +} + +/// Get an existing work and all available information from related tables. +pub fn get_work(conn: &DbConn, id: &str) -> Result> { + let work = match get_work_row(conn, id)? { + Some(row) => Some(get_description_for_work_row(conn, &row)?), + None => None, + }; + + Ok(work) +} + +/// Delete an existing work. This will fail if there are still other tables that relate to +/// this work except for the things that are part of the information on the work itself. Also, +/// this will only succeed, if the provided user is allowed to delete the work. +pub fn delete_work(conn: &DbConn, id: &str, user: &User) -> Result<()> { + if user.may_delete() { + diesel::delete(works::table.filter(works::id.eq(id))).execute(conn)?; + Ok(()) + } else { + Err(Error::new(ServerError::Forbidden)) + } +} + +/// Get all existing works by a composer and related information from other tables. +pub fn get_works(conn: &DbConn, composer_id: &str) -> Result> { + let mut works: Vec = Vec::new(); + + let rows = works::table + .filter(works::composer.eq(composer_id)) + .load::(conn)?; + + for row in rows { + works.push(get_description_for_work_row(conn, &row)?); + } + + Ok(works) +} + +/// Get an already existing work without related rows from other tables. +fn get_work_row(conn: &DbConn, id: &str) -> Result> { + Ok(works::table + .filter(works::id.eq(id)) + .load::(conn)? + .into_iter() + .next()) +} + +/// Retrieve all available information on a work from related tables. +fn get_description_for_work_row(conn: &DbConn, row: &WorkRow) -> Result { + let mut instruments: Vec = Vec::new(); + + let instrumentations = instrumentations::table + .filter(instrumentations::work.eq(&row.id)) + .load::(conn)?; + + for instrumentation in instrumentations { + let id = instrumentation.instrument.clone(); + instruments + .push(get_instrument(conn, &id)?.ok_or(anyhow!("No instrument with ID: {}", id))?); + } + + let mut parts: Vec = Vec::new(); + + let part_rows = work_parts::table + .filter(work_parts::work.eq(&row.id)) + .load::(conn)?; + + for part_row in part_rows { + parts.push(WorkPart { + title: part_row.title, + composer: match part_row.composer { + Some(id) => { + Some(get_person(conn, &id)?.ok_or(anyhow!("No person with ID: {}", id))?) + } + None => None, + }, + }); + } + + let mut sections: Vec = Vec::new(); + + let section_rows = work_sections::table + .filter(work_sections::work.eq(&row.id)) + .load::(conn)?; + + for section in section_rows { + sections.push(WorkSection { + title: section.title, + before_index: section.before_index, + }); + } + + let id = &row.composer; + let composer = get_person(conn, id)?.ok_or(anyhow!("No person with ID: {}", id))?; + + Ok(Work { + id: row.id.clone(), + composer, + title: row.title.clone(), + instruments, + parts, + sections, + }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..089aba8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,50 @@ +use actix_web::{dev::HttpResponseBuilder, error, http::StatusCode, HttpResponse}; +use derive_more::{Display, Error}; + +/// An error intended for the public interface. +#[derive(Display, Error, Debug)] +pub enum ServerError { + NotFound, + Unauthorized, + Forbidden, + Internal, +} + +impl error::ResponseError for ServerError { + fn error_response(&self) -> HttpResponse { + HttpResponseBuilder::new(self.status_code()).finish() + } + + fn status_code(&self) -> StatusCode { + match self { + ServerError::NotFound => StatusCode::NOT_FOUND, + ServerError::Unauthorized => StatusCode::UNAUTHORIZED, + ServerError::Forbidden => StatusCode::FORBIDDEN, + ServerError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ServerError { + fn from(_: r2d2::Error) -> Self { + ServerError::Internal + } +} + +impl From for ServerError { + fn from(error: anyhow::Error) -> Self { + match error.downcast() { + Ok(error) => error, + Err(_) => ServerError::Internal, + } + } +} + +impl From> for ServerError { + fn from(error: error::BlockingError) -> Self { + match error { + error::BlockingError::Error(error) => error, + error::BlockingError::Canceled => ServerError::Internal, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..eb0edd8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,57 @@ +// Required for database/schema.rs +#[macro_use] +extern crate diesel; + +// Required for embed_migrations macro in database/mod.rs +#[macro_use] +extern crate diesel_migrations; + +use actix_web::{App, HttpServer}; + +mod database; +mod error; + +mod routes; +use routes::*; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + dotenv::dotenv().ok(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + sodiumoxide::init().expect("Failed to init crypto library!"); + let db_pool = database::connect().expect("Failed to create database interface!"); + + let server = HttpServer::new(move || { + App::new() + .data(db_pool.clone()) + .wrap(actix_web::middleware::Logger::new( + "%t: %r -> %s; %b B; %D ms", + )) + .service(register_user) + .service(login_user) + .service(put_user) + .service(get_user) + .service(get_person) + .service(update_person) + .service(get_persons) + .service(delete_person) + .service(get_ensemble) + .service(update_ensemble) + .service(delete_ensemble) + .service(get_ensembles) + .service(get_instrument) + .service(update_instrument) + .service(delete_instrument) + .service(get_instruments) + .service(get_work) + .service(update_work) + .service(delete_work) + .service(get_works) + .service(get_recording) + .service(update_recording) + .service(delete_recording) + .service(get_recordings_for_work) + }); + + server.bind("127.0.0.1:8087")?.run().await +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..7bc0f6c --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,235 @@ +use crate::database; +use crate::database::{DbConn, DbPool, User, UserInsertion}; +use crate::error::ServerError; +use actix_web::{get, post, put, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use sodiumoxide::crypto::pwhash::argon2id13; + +/// Request body data for user registration. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UserRegistration { + pub username: String, + pub password: String, + pub email: Option, +} + +/// Request body data for user login. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Login { + pub username: String, + pub password: String, +} + +/// Request body data for changing user details. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PutUser { + pub old_password: String, + pub new_password: Option, + pub email: Option, +} + +/// Response body data for getting a user. +#[derive(Serialize, Debug, Clone)] +pub struct GetUser { + pub username: String, + pub email: Option, +} + +/// Claims for issued JWTs. +#[derive(Deserialize, Serialize, Debug, Clone)] +struct Claims { + pub iat: u64, + pub exp: u64, + pub username: String, +} + +/// Register a new user. +#[post("/users")] +pub async fn register_user( + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get().or(Err(ServerError::Internal))?; + + database::insert_user( + &conn, + &data.username, + &UserInsertion { + password_hash: hash_password(&data.password).or(Err(ServerError::Internal))?, + email: data.email.clone(), + }, + ) + .or(Err(ServerError::Internal)) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +/// Update an existing user. This doesn't use a JWT for authentication but requires the client to +/// resent the old password. +#[put("/users/{username}")] +pub async fn put_user( + db: web::Data, + username: web::Path, + data: web::Json, +) -> Result { + let conn = db.into_inner().get().or(Err(ServerError::Internal))?; + + web::block(move || { + let user = database::get_user(&conn, &username) + .or(Err(ServerError::Internal))? + .ok_or(ServerError::Unauthorized)?; + + if verify_password(&data.old_password, &user.password_hash) { + let password_hash = match &data.new_password { + Some(password) => hash_password(password).or(Err(ServerError::Unauthorized))?, + None => user.password_hash.clone(), + }; + + database::update_user( + &conn, + &username, + &UserInsertion { + email: data.email.clone(), + password_hash, + }, + ) + .or(Err(ServerError::Internal))?; + + Ok(()) + } else { + Err(ServerError::Forbidden) + } + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +/// Get an existing user. This requires a valid JWT authenticating that user. +#[get("/users/{username}")] +pub async fn get_user( + db: web::Data, + username: web::Path, + auth: BearerAuth, +) -> Result { + let user = web::block(move || { + let conn = db.into_inner().get().or(Err(ServerError::Internal))?; + authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized)) + }) + .await?; + + if username.into_inner() != user.username { + Err(ServerError::Forbidden)?; + } + + Ok(HttpResponse::Ok().json(GetUser { + username: user.username, + email: user.email, + })) +} + +/// Login an already existing user. This will respond with a newly issued JWT. +#[post("/login")] +pub async fn login_user( + db: web::Data, + data: web::Json, +) -> Result { + let token = web::block(move || { + let conn = db.into_inner().get().or(Err(ServerError::Internal))?; + + let user = database::get_user(&conn, &data.username) + .or(Err(ServerError::Internal))? + .ok_or(ServerError::Unauthorized)?; + + if verify_password(&data.password, &user.password_hash) { + issue_jwt(&user.username).or(Err(ServerError::Internal)) + } else { + Err(ServerError::Unauthorized) + } + }) + .await?; + + Ok(HttpResponse::Ok().body(token)) +} + +/// Authenticate a user by verifying the provided token. The environemtn variable "WOLFGANG_SECRET" +/// will be used as the secret key and has to be set. +pub fn authenticate(conn: &DbConn, token: &str) -> Result { + let username = verify_jwt(token)?.username; + database::get_user(conn, &username)?.ok_or(anyhow!("User doesn't exist: {}", &username)) +} + +/// Return a hash for a password that can be stored in the database. +fn hash_password(password: &str) -> Result { + let hash = argon2id13::pwhash( + password.as_bytes(), + argon2id13::OPSLIMIT_INTERACTIVE, + argon2id13::MEMLIMIT_INTERACTIVE, + ) + .or(Err(anyhow!("Failed to hash password!")))?; + + // Strip trailing null bytes to facilitate database storage. + Ok(std::str::from_utf8(&hash.0)? + .trim_end_matches('\u{0}') + .to_string()) +} + +/// Verify whether a hash is valid for a password. +fn verify_password(password: &str, hash: &str) -> bool { + // Readd the trailing null bytes padding. + let mut bytes = [0u8; 128]; + for (index, byte) in hash.as_bytes().iter().enumerate() { + bytes[index] = *byte; + } + + argon2id13::pwhash_verify( + &argon2id13::HashedPassword::from_slice(&bytes).unwrap(), + password.as_bytes(), + ) +} + +/// Issue a JWT that allows to claim to be a user. This uses the value of the environment variable +/// "WOLFGANG_SECRET" as the secret key. This needs to be set. +fn issue_jwt(username: &str) -> Result { + let now = std::time::SystemTime::now(); + let expiry = now + std::time::Duration::new(86400, 0); + + let iat = now.duration_since(std::time::UNIX_EPOCH)?.as_secs(); + let exp = expiry.duration_since(std::time::UNIX_EPOCH)?.as_secs(); + + let secret = std::env::var("WOLFGANG_SECRET")?; + + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &Claims { + iat, + exp, + username: username.to_string(), + }, + &jsonwebtoken::EncodingKey::from_secret(&secret.as_bytes()), + )?; + + Ok(token) +} + +/// Verify a JWT and return the claims that are made by it. This uses the value of the environment +/// variable "WOLFGANG_SECRET" as the secret key. This needs to be set. +fn verify_jwt(token: &str) -> Result { + let secret = std::env::var("WOLFGANG_SECRET")?; + + let jwt = jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(&secret.as_bytes()), + &jsonwebtoken::Validation::default(), + )?; + + Ok(jwt.claims) +} diff --git a/src/routes/ensembles.rs b/src/routes/ensembles.rs new file mode 100644 index 0000000..11d671a --- /dev/null +++ b/src/routes/ensembles.rs @@ -0,0 +1,71 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Ensemble}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing ensemble. +#[get("/ensembles/{id}")] +pub async fn get_ensemble( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_ensemble(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new ensemble or update an existin one. The user must be authorized to do that. +#[post("/ensembles")] +pub async fn update_ensemble( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_ensemble(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/ensembles")] +pub async fn get_ensembles(db: web::Data) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_ensembles(&conn)?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/ensembles/{id}")] +pub async fn delete_ensemble( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_ensemble(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/routes/instruments.rs b/src/routes/instruments.rs new file mode 100644 index 0000000..2320fb6 --- /dev/null +++ b/src/routes/instruments.rs @@ -0,0 +1,71 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Instrument}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing instrument. +#[get("/instruments/{id}")] +pub async fn get_instrument( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_instrument(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new instrument or update an existin one. The user must be authorized to do that. +#[post("/instruments")] +pub async fn update_instrument( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_instrument(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/instruments")] +pub async fn get_instruments(db: web::Data) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_instruments(&conn)?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/instruments/{id}")] +pub async fn delete_instrument( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_instrument(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..c35ce5a --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,17 @@ +pub mod auth; +pub use auth::*; + +pub mod ensembles; +pub use ensembles::*; + +pub mod instruments; +pub use instruments::*; + +pub mod persons; +pub use persons::*; + +pub mod recordings; +pub use recordings::*; + +pub mod works; +pub use works::*; \ No newline at end of file diff --git a/src/routes/persons.rs b/src/routes/persons.rs new file mode 100644 index 0000000..977e3b3 --- /dev/null +++ b/src/routes/persons.rs @@ -0,0 +1,71 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Person}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing person. +#[get("/persons/{id}")] +pub async fn get_person( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_person(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new person or update an existin one. The user must be authorized to do that. +#[post("/persons")] +pub async fn update_person( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_person(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/persons")] +pub async fn get_persons(db: web::Data) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_persons(&conn)?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/persons/{id}")] +pub async fn delete_person( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_person(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/routes/recordings.rs b/src/routes/recordings.rs new file mode 100644 index 0000000..c3ad273 --- /dev/null +++ b/src/routes/recordings.rs @@ -0,0 +1,102 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Recording}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing recording. +#[get("/recordings/{id}")] +pub async fn get_recording( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_recording(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new recording or update an existin one. The user must be authorized to do that. +#[post("/recordings")] +pub async fn update_recording( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_recording(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/works/{id}/recordings")] +pub async fn get_recordings_for_work( + db: web::Data, + work_id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_recordings_for_work(&conn, &work_id.into_inner())?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[get("/persons/{id}/recordings")] +pub async fn get_recordings_for_person( + db: web::Data, + person_id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_recordings_for_person(&conn, &person_id.into_inner())?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[get("/ensembles/{id}/recordings")] +pub async fn get_recordings_for_ensemble( + db: web::Data, + ensemble_id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_recordings_for_ensemble(&conn, &ensemble_id.into_inner())?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/recordings/{id}")] +pub async fn delete_recording( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_recording(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/routes/works.rs b/src/routes/works.rs new file mode 100644 index 0000000..ad8a8e9 --- /dev/null +++ b/src/routes/works.rs @@ -0,0 +1,74 @@ +use super::authenticate; +use crate::database; +use crate::database::{DbPool, Work}; +use crate::error::ServerError; +use actix_web::{delete, get, post, web, HttpResponse}; +use actix_web_httpauth::extractors::bearer::BearerAuth; + +/// Get an existing work. +#[get("/works/{id}")] +pub async fn get_work( + db: web::Data, + id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + database::get_work(&conn, &id.into_inner())?.ok_or(ServerError::NotFound) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +/// Add a new work or update an existin one. The user must be authorized to do that. +#[post("/works")] +pub async fn update_work( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::update_work(&conn, &data.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/persons/{id}/works")] +pub async fn get_works( + db: web::Data, + composer_id: web::Path, +) -> Result { + let data = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_works(&conn, &composer_id.into_inner())?) + }) + .await?; + + Ok(HttpResponse::Ok().json(data)) +} + +#[delete("/works/{id}")] +pub async fn delete_work( + auth: BearerAuth, + db: web::Data, + id: web::Path, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::delete_work(&conn, &id.into_inner(), &user)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +}