From d0c25531d364aa6c8bda1ecc44fec9040e744c28 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sat, 14 Nov 2020 23:08:37 +0100 Subject: [PATCH] Add server --- README.md | 6 +- musicus/README.md | 17 +- musicus_server/.gitignore | 3 + musicus_server/Cargo.toml | 19 ++ musicus_server/README.md | 50 +++ musicus_server/diesel.toml | 2 + .../down.sql | 6 + .../up.sql | 36 ++ .../2020-11-09-153819_initial_schema/down.sql | 19 ++ .../2020-11-09-153819_initial_schema/up.sql | 70 ++++ musicus_server/src/database/ensembles.rs | 75 +++++ musicus_server/src/database/instruments.rs | 75 +++++ musicus_server/src/database/mod.rs | 39 +++ musicus_server/src/database/persons.rs | 78 +++++ musicus_server/src/database/recordings.rs | 278 ++++++++++++++++ musicus_server/src/database/schema.rs | 120 +++++++ musicus_server/src/database/users.rs | 61 ++++ musicus_server/src/database/works.rs | 307 ++++++++++++++++++ musicus_server/src/main.rs | 36 ++ musicus_server/src/routes/auth.rs | 261 +++++++++++++++ musicus_server/src/routes/ensembles.rs | 0 musicus_server/src/routes/error.rs | 47 +++ musicus_server/src/routes/instruments.rs | 0 musicus_server/src/routes/mod.rs | 20 ++ musicus_server/src/routes/persons.rs | 103 ++++++ musicus_server/src/routes/recordings.rs | 0 musicus_server/src/routes/works.rs | 0 27 files changed, 1725 insertions(+), 3 deletions(-) create mode 100644 musicus_server/.gitignore create mode 100644 musicus_server/Cargo.toml create mode 100644 musicus_server/README.md create mode 100644 musicus_server/diesel.toml create mode 100644 musicus_server/migrations/00000000000000_diesel_initial_setup/down.sql create mode 100644 musicus_server/migrations/00000000000000_diesel_initial_setup/up.sql create mode 100644 musicus_server/migrations/2020-11-09-153819_initial_schema/down.sql create mode 100644 musicus_server/migrations/2020-11-09-153819_initial_schema/up.sql create mode 100644 musicus_server/src/database/ensembles.rs create mode 100644 musicus_server/src/database/instruments.rs create mode 100644 musicus_server/src/database/mod.rs create mode 100644 musicus_server/src/database/persons.rs create mode 100644 musicus_server/src/database/recordings.rs create mode 100644 musicus_server/src/database/schema.rs create mode 100644 musicus_server/src/database/users.rs create mode 100644 musicus_server/src/database/works.rs create mode 100644 musicus_server/src/main.rs create mode 100644 musicus_server/src/routes/auth.rs create mode 100644 musicus_server/src/routes/ensembles.rs create mode 100644 musicus_server/src/routes/error.rs create mode 100644 musicus_server/src/routes/instruments.rs create mode 100644 musicus_server/src/routes/mod.rs create mode 100644 musicus_server/src/routes/persons.rs create mode 100644 musicus_server/src/routes/recordings.rs create mode 100644 musicus_server/src/routes/works.rs diff --git a/README.md b/README.md index 10bb7a9..68f4075 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ https://musicus.org ## Repository structure The subdirectories contain toplevel components of the Musicus system. Currently -you will only find a Musicus desktop app under `musicus`. The component READMEs -provide more detailed information. +you will find: + + * `musicus` – Musicus desktop app for Linux + * `musicus_server` – Musicus server ## License diff --git a/musicus/README.md b/musicus/README.md index 080f29e..342cd95 100644 --- a/musicus/README.md +++ b/musicus/README.md @@ -57,4 +57,19 @@ command: $ diesel migration run --database-url test.sqlite ``` -This file should never be edited manually. \ No newline at end of file +This file should never be edited manually. + +## License + +Musicus 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. + +Musicus 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/. \ No newline at end of file diff --git a/musicus_server/.gitignore b/musicus_server/.gitignore new file mode 100644 index 0000000..93fdb52 --- /dev/null +++ b/musicus_server/.gitignore @@ -0,0 +1,3 @@ +/.env +/Cargo.lock +/target \ No newline at end of file diff --git a/musicus_server/Cargo.toml b/musicus_server/Cargo.toml new file mode 100644 index 0000000..31163ec --- /dev/null +++ b/musicus_server/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "musicus_server" +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"] } +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/musicus_server/README.md b/musicus_server/README.md new file mode 100644 index 0000000..c8190f8 --- /dev/null +++ b/musicus_server/README.md @@ -0,0 +1,50 @@ +# Musicus Server + +This is a server for hosting metadata on classical music. + +## Running + +The Musicus server 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 the Musicus server itself use the +environment variable `MUSICUS_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 "MUSICUS_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 "MUSICUS_SECRET=\"$(openssl rand -base64 64)\"" >> .env +``` + +## Hacking + +The Musicus server 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 + +Musicus 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. + +Musicus 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/. \ No newline at end of file diff --git a/musicus_server/diesel.toml b/musicus_server/diesel.toml new file mode 100644 index 0000000..d3e7db2 --- /dev/null +++ b/musicus_server/diesel.toml @@ -0,0 +1,2 @@ +[print_schema] +file = "src/database/schema.rs" \ No newline at end of file diff --git a/musicus_server/migrations/00000000000000_diesel_initial_setup/down.sql b/musicus_server/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/musicus_server/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/musicus_server/migrations/00000000000000_diesel_initial_setup/up.sql b/musicus_server/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/musicus_server/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/musicus_server/migrations/2020-11-09-153819_initial_schema/down.sql b/musicus_server/migrations/2020-11-09-153819_initial_schema/down.sql new file mode 100644 index 0000000..8c6540d --- /dev/null +++ b/musicus_server/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/musicus_server/migrations/2020-11-09-153819_initial_schema/up.sql b/musicus_server/migrations/2020-11-09-153819_initial_schema/up.sql new file mode 100644 index 0000000..cef642f --- /dev/null +++ b/musicus_server/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 BIGINT 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 BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE works ( + id BIGINT NOT NULL PRIMARY KEY, + composer BIGINT 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 BIGINT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + instrument BIGINT NOT NULL REFERENCES instruments(id) ON DELETE CASCADE +); + +CREATE TABLE work_parts ( + id BIGINT NOT NULL PRIMARY KEY, + work BIGINT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + part_index BIGINT NOT NULL, + title TEXT NOT NULL, + composer BIGINT REFERENCES persons(id) +); + +CREATE TABLE work_sections ( + id BIGINT NOT NULL PRIMARY KEY, + work BIGINT NOT NULL REFERENCES works(id) ON DELETE CASCADE, + title TEXT NOT NULL, + before_index BIGINT NOT NULL +); + +CREATE TABLE ensembles ( + id BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(username) +); + +CREATE TABLE recordings ( + id BIGINT NOT NULL PRIMARY KEY, + work BIGINT 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 BIGINT NOT NULL REFERENCES recordings(id) ON DELETE CASCADE, + person BIGINT REFERENCES persons(id), + ensemble BIGINT REFERENCES ensembles(id), + role BIGINT REFERENCES instruments(id) +); \ No newline at end of file diff --git a/musicus_server/src/database/ensembles.rs b/musicus_server/src/database/ensembles.rs new file mode 100644 index 0000000..8bb8468 --- /dev/null +++ b/musicus_server/src/database/ensembles.rs @@ -0,0 +1,75 @@ +use super::schema::ensembles; +use super::DbConn; +use anyhow::Result; +use diesel::prelude::*; +use diesel::{Insertable, Queryable}; +use serde::{Deserialize, Serialize}; + +/// An ensemble that takes part in recordings. +#[derive(Insertable, Queryable, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Ensemble { + pub id: i64, + pub name: String, + + #[serde(skip)] + pub created_by: String, +} + +/// A structure representing data on an ensemble. +#[derive(AsChangeset, Deserialize, Debug, Clone)] +#[table_name = "ensembles"] +#[serde(rename_all = "camelCase")] +pub struct EnsembleInsertion { + pub name: String, +} + +/// Insert a new ensemble. +pub fn insert_ensemble( + conn: &DbConn, + id: u32, + data: &EnsembleInsertion, + created_by: &str, +) -> Result<()> { + let ensemble = Ensemble { + id: id as i64, + name: data.name.clone(), + created_by: created_by.to_string(), + }; + + diesel::insert_into(ensembles::table) + .values(ensemble) + .execute(conn)?; + + Ok(()) +} + +/// Update an existing ensemble. +pub fn update_ensemble(conn: &DbConn, id: u32, data: &EnsembleInsertion) -> Result<()> { + diesel::update(ensembles::table) + .filter(ensembles::id.eq(id as i64)) + .set(data) + .execute(conn)?; + + Ok(()) +} + +/// Get an existing ensemble. +pub fn get_ensemble(conn: &DbConn, id: u32) -> Result> { + Ok(ensembles::table + .filter(ensembles::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned()) +} + +/// Delete an existing ensemble. +pub fn delete_ensemble(conn: &DbConn, id: u32) -> Result<()> { + diesel::delete(ensembles::table.filter(ensembles::id.eq(id as i64))).execute(conn)?; + Ok(()) +} + +/// Get all existing ensembles. +pub fn get_ensembles(conn: &DbConn) -> Result> { + Ok(ensembles::table.load::(conn)?) +} diff --git a/musicus_server/src/database/instruments.rs b/musicus_server/src/database/instruments.rs new file mode 100644 index 0000000..8e32f77 --- /dev/null +++ b/musicus_server/src/database/instruments.rs @@ -0,0 +1,75 @@ +use super::schema::instruments; +use super::DbConn; +use anyhow::Result; +use diesel::prelude::*; +use diesel::{Insertable, Queryable}; +use serde::{Deserialize, Serialize}; + +/// An instrument or any other possible role within a recording. +#[derive(Insertable, Queryable, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Instrument { + pub id: i64, + pub name: String, + + #[serde(skip)] + pub created_by: String, +} + +/// A structure representing data on an instrument. +#[derive(AsChangeset, Deserialize, Debug, Clone)] +#[table_name = "instruments"] +#[serde(rename_all = "camelCase")] +pub struct InstrumentInsertion { + pub name: String, +} + +/// Insert a new instrument. +pub fn insert_instrument( + conn: &DbConn, + id: u32, + data: &InstrumentInsertion, + created_by: &str, +) -> Result<()> { + let instrument = Instrument { + id: id as i64, + name: data.name.clone(), + created_by: created_by.to_string(), + }; + + diesel::insert_into(instruments::table) + .values(instrument) + .execute(conn)?; + + Ok(()) +} + +/// Update an existing instrument. +pub fn update_instrument(conn: &DbConn, id: u32, data: &InstrumentInsertion) -> Result<()> { + diesel::update(instruments::table) + .filter(instruments::id.eq(id as i64)) + .set(data) + .execute(conn)?; + + Ok(()) +} + +/// Get an existing instrument. +pub fn get_instrument(conn: &DbConn, id: u32) -> Result> { + Ok(instruments::table + .filter(instruments::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned()) +} + +/// Delete an existing instrument. +pub fn delete_instrument(conn: &DbConn, id: u32) -> Result<()> { + diesel::delete(instruments::table.filter(instruments::id.eq(id as i64))).execute(conn)?; + Ok(()) +} + +/// Get all existing instruments. +pub fn get_instruments(conn: &DbConn) -> Result> { + Ok(instruments::table.load::(conn)?) +} \ No newline at end of file diff --git a/musicus_server/src/database/mod.rs b/musicus_server/src/database/mod.rs new file mode 100644 index 0000000..da93e46 --- /dev/null +++ b/musicus_server/src/database/mod.rs @@ -0,0 +1,39 @@ +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; + +/// 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 +/// "MUSICUS_DATABASE_URL" environment variable and fail, if that is not set. +pub fn connect() -> Result { + let url = std::env::var("MUSICUS_DATABASE_URL")?; + let manager = r2d2::ConnectionManager::::new(url); + let pool = r2d2::Pool::new(manager)?; + + Ok(pool) +} diff --git a/musicus_server/src/database/persons.rs b/musicus_server/src/database/persons.rs new file mode 100644 index 0000000..1e54155 --- /dev/null +++ b/musicus_server/src/database/persons.rs @@ -0,0 +1,78 @@ +use super::schema::persons; +use super::DbConn; +use anyhow::Result; +use diesel::prelude::*; +use diesel::{Insertable, Queryable}; +use serde::{Deserialize, Serialize}; + +/// A person that is a composer, an interpret or both. +#[derive(Insertable, Queryable, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Person { + pub id: i64, + pub first_name: String, + pub last_name: String, + + #[serde(skip)] + pub created_by: String, +} + +/// A structure representing data on a person. +#[derive(AsChangeset, Deserialize, Debug, Clone)] +#[table_name = "persons"] +#[serde(rename_all = "camelCase")] +pub struct PersonInsertion { + pub first_name: String, + pub last_name: String, +} + +/// Insert a new person. +pub fn insert_person( + conn: &DbConn, + id: u32, + data: &PersonInsertion, + created_by: &str, +) -> Result<()> { + let person = Person { + id: id as i64, + first_name: data.first_name.clone(), + last_name: data.last_name.clone(), + created_by: created_by.to_string(), + }; + + diesel::insert_into(persons::table) + .values(person) + .execute(conn)?; + + Ok(()) +} + +/// Update an existing person. +pub fn update_person(conn: &DbConn, id: u32, data: &PersonInsertion) -> Result<()> { + diesel::update(persons::table) + .filter(persons::id.eq(id as i64)) + .set(data) + .execute(conn)?; + + Ok(()) +} + +/// Get an existing person. +pub fn get_person(conn: &DbConn, id: u32) -> Result> { + Ok(persons::table + .filter(persons::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned()) +} + +/// Delete an existing person. +pub fn delete_person(conn: &DbConn, id: u32) -> Result<()> { + diesel::delete(persons::table.filter(persons::id.eq(id as i64))).execute(conn)?; + Ok(()) +} + +/// Get all existing persons. +pub fn get_persons(conn: &DbConn) -> Result> { + Ok(persons::table.load::(conn)?) +} diff --git a/musicus_server/src/database/recordings.rs b/musicus_server/src/database/recordings.rs new file mode 100644 index 0000000..8f88c62 --- /dev/null +++ b/musicus_server/src/database/recordings.rs @@ -0,0 +1,278 @@ +use super::schema::{ensembles, instruments, performances, persons, recordings}; +use super::{get_work_description, DbConn, Ensemble, Instrument, Person, WorkDescription}; +use anyhow::{anyhow, Error, Result}; +use diesel::prelude::*; +use diesel::{Insertable, Queryable}; +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; + +/// A specific recording of a work. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct Recording { + pub id: i64, + pub work: i64, + pub comment: String, + pub created_by: String, +} + +/// How a person or ensemble was involved in a recording. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct Performance { + pub id: i64, + pub recording: i64, + pub person: Option, + pub ensemble: Option, + pub role: Option, +} + +/// A structure for collecting all available information on a performance. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceDescription { + pub person: Option, + pub ensemble: Option, + pub role: Option, +} + +/// A structure for collecting all available information on a recording. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecordingDescription { + pub id: i64, + pub work: WorkDescription, + pub comment: String, + pub performances: Vec, +} + +/// A structure representing data on a performance. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceInsertion { + pub person: Option, + pub ensemble: Option, + pub role: Option, +} + +/// A bundle of everything needed for adding a new recording to the database. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecordingInsertion { + pub work: i64, + pub comment: String, + pub performances: Vec, +} + +/// Insert a new recording. +pub fn insert_recording( + conn: &DbConn, + id: u32, + data: &RecordingInsertion, + created_by: &str, +) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let id = id as i64; + + diesel::insert_into(recordings::table) + .values(Recording { + id, + work: data.work, + comment: data.comment.clone(), + created_by: created_by.to_string(), + }) + .execute(conn)?; + + insert_recording_data(conn, id, data)?; + + Ok(()) + })?; + + Ok(()) +} + +/// Update an existing recording. +pub fn update_recording(conn: &DbConn, id: u32, data: &RecordingInsertion) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let id = id as i64; + + diesel::delete(performances::table) + .filter(performances::recording.eq(id)) + .execute(conn)?; + + diesel::update(recordings::table) + .filter(recordings::id.eq(id)) + .set(( + recordings::work.eq(data.work), + recordings::comment.eq(data.comment.clone()), + )) + .execute(conn)?; + + insert_recording_data(conn, id, data)?; + + Ok(()) + })?; + + Ok(()) +} + +/// Helper method to populate other tables related to a recording. +fn insert_recording_data(conn: &DbConn, id: i64, data: &RecordingInsertion) -> Result<()> { + for performance in &data.performances { + diesel::insert_into(performances::table) + .values(Performance { + id: rand::random(), + recording: id, + person: performance.person, + ensemble: performance.ensemble, + role: performance.role, + }) + .execute(conn)?; + } + + Ok(()) +} + +/// Get an existing recording. +pub fn get_recording(conn: &DbConn, id: u32) -> Result> { + Ok(recordings::table + .filter(recordings::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned()) +} + +/// Retrieve all available information on a recording from related tables. +pub fn get_description_for_recording( + conn: &DbConn, + recording: &Recording, +) -> Result { + let mut performance_descriptions: Vec = Vec::new(); + + let performances = performances::table + .filter(performances::recording.eq(recording.id)) + .load::(conn)?; + + for performance in performances { + performance_descriptions.push(PerformanceDescription { + person: match performance.person { + Some(id) => Some( + persons::table + .filter(persons::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned() + .ok_or(anyhow!("No person with ID: {}", id))?, + ), + None => None, + }, + ensemble: match performance.ensemble { + Some(id) => Some( + ensembles::table + .filter(ensembles::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned() + .ok_or(anyhow!("No ensemble with ID: {}", id))?, + ), + None => None, + }, + role: match performance.role { + Some(id) => Some( + instruments::table + .filter(instruments::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned() + .ok_or(anyhow!("No instrument with ID: {}", id))?, + ), + None => None, + }, + }); + } + + let work_id = recording.work.try_into()?; + let work = + get_work_description(conn, work_id)?.ok_or(anyhow!("Work doesn't exist: {}", work_id))?; + + let recording_description = RecordingDescription { + id: recording.id, + work, + comment: recording.comment.clone(), + performances: performance_descriptions, + }; + + Ok(recording_description) +} + +/// Get an existing recording and all available information from related tables. +pub fn get_recording_description(conn: &DbConn, id: u32) -> Result> { + let recording_description = match get_recording(conn, id)? { + Some(recording) => Some(get_description_for_recording(conn, &recording)?), + None => None, + }; + + Ok(recording_description) +} + +/// Get all available information on all recordings where a person is performing. +pub fn get_recordings_for_person( + conn: &DbConn, + person_id: u32, +) -> Result> { + let mut recording_descriptions: Vec = Vec::new(); + + let recordings = 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 as i64)) + .select(recordings::table::all_columns()) + .load::(conn)?; + + for recording in recordings { + recording_descriptions.push(get_description_for_recording(conn, &recording)?); + } + + Ok(recording_descriptions) +} + +/// Get all available information on all recordings where an ensemble is performing. +pub fn get_recordings_for_ensemble( + conn: &DbConn, + ensemble_id: u32, +) -> Result> { + let mut recording_descriptions: Vec = Vec::new(); + + let recordings = 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 as i64)) + .select(recordings::table::all_columns()) + .load::(conn)?; + + for recording in recordings { + recording_descriptions.push(get_description_for_recording(conn, &recording)?); + } + + Ok(recording_descriptions) +} + +/// Get allavailable information on all recordings of a work. +pub fn get_recordings_for_work(conn: &DbConn, work_id: u32) -> Result> { + let mut recording_descriptions: Vec = Vec::new(); + + let recordings = recordings::table + .filter(recordings::work.eq(work_id as i64)) + .load::(conn)?; + + for recording in recordings { + recording_descriptions.push(get_description_for_recording(conn, &recording)?); + } + + Ok(recording_descriptions) +} + +/// 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. +pub fn delete_recording(conn: &DbConn, id: u32) -> Result<()> { + diesel::delete(recordings::table.filter(recordings::id.eq(id as i64))).execute(conn)?; + Ok(()) +} diff --git a/musicus_server/src/database/schema.rs b/musicus_server/src/database/schema.rs new file mode 100644 index 0000000..e9d74e7 --- /dev/null +++ b/musicus_server/src/database/schema.rs @@ -0,0 +1,120 @@ +table! { + ensembles (id) { + id -> Int8, + name -> Text, + created_by -> Text, + } +} + +table! { + instrumentations (id) { + id -> Int8, + work -> Int8, + instrument -> Int8, + } +} + +table! { + instruments (id) { + id -> Int8, + name -> Text, + created_by -> Text, + } +} + +table! { + performances (id) { + id -> Int8, + recording -> Int8, + person -> Nullable, + ensemble -> Nullable, + role -> Nullable, + } +} + +table! { + persons (id) { + id -> Int8, + first_name -> Text, + last_name -> Text, + created_by -> Text, + } +} + +table! { + recordings (id) { + id -> Int8, + work -> Int8, + 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 -> Int8, + part_index -> Int8, + title -> Text, + composer -> Nullable, + } +} + +table! { + work_sections (id) { + id -> Int8, + work -> Int8, + title -> Text, + before_index -> Int8, + } +} + +table! { + works (id) { + id -> Int8, + composer -> Int8, + 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/musicus_server/src/database/users.rs b/musicus_server/src/database/users.rs new file mode 100644 index 0000000..ce06229 --- /dev/null +++ b/musicus_server/src/database/users.rs @@ -0,0 +1,61 @@ +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, +} + +/// 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/musicus_server/src/database/works.rs b/musicus_server/src/database/works.rs new file mode 100644 index 0000000..ca5630e --- /dev/null +++ b/musicus_server/src/database/works.rs @@ -0,0 +1,307 @@ +use super::schema::{instrumentations, instruments, persons, work_parts, work_sections, works}; +use super::{get_person, DbConn, Instrument, Person}; +use anyhow::{anyhow, Error, Result}; +use diesel::prelude::*; +use diesel::{Insertable, Queryable}; +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; + +/// A composition by a composer. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct Work { + pub id: i64, + pub composer: i64, + pub title: String, + pub created_by: String, +} + +/// Definition that a work uses an instrument. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct Instrumentation { + pub id: i64, + pub work: i64, + pub instrument: i64, +} + +/// A concrete work part that can be recorded. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct WorkPart { + pub id: i64, + pub work: i64, + pub part_index: i64, + pub title: String, + pub composer: Option, +} + +/// A heading between work parts. +#[derive(Insertable, Queryable, Debug, Clone)] +pub struct WorkSection { + pub id: i64, + pub work: i64, + pub title: String, + pub before_index: i64, +} +/// A structure for collecting all available information on a work part. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkPartDescription { + pub title: String, + pub composer: Option, +} + +/// A structure for collecting all available information on a work section. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkSectionDescription { + pub title: String, + pub before_index: i64, +} + +/// A structure for collecting all available information on a work. +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkDescription { + pub id: i64, + pub title: String, + pub composer: Person, + pub instruments: Vec, + pub parts: Vec, + pub sections: Vec, +} + +/// A structure representing data on a work part. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkPartInsertion { + pub title: String, + pub composer: Option, +} + +/// A structure representing data on a work section. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkSectionInsertion { + pub title: String, + pub before_index: i64, +} + +/// A structure representing data on a work. +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkInsertion { + pub composer: i64, + pub title: String, + pub instruments: Vec, + pub parts: Vec, + pub sections: Vec, +} + +/// Insert a new work. +pub fn insert_work(conn: &DbConn, id: u32, data: &WorkInsertion, created_by: &str) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let id = id as i64; + + diesel::insert_into(works::table) + .values(Work { + id, + composer: data.composer.clone(), + title: data.title.clone(), + created_by: created_by.to_string(), + }) + .execute(conn)?; + + insert_work_data(conn, id, data)?; + + Ok(()) + })?; + + Ok(()) +} + +/// Update an existing work. +pub fn update_work(conn: &DbConn, id: u32, data: &WorkInsertion) -> Result<()> { + conn.transaction::<(), Error, _>(|| { + let id = id as i64; + + diesel::delete(instrumentations::table) + .filter(instrumentations::work.eq(id)) + .execute(conn)?; + + diesel::delete(work_parts::table) + .filter(work_parts::work.eq(id)) + .execute(conn)?; + + diesel::delete(work_sections::table) + .filter(work_sections::work.eq(id)) + .execute(conn)?; + + diesel::update(works::table) + .filter(works::id.eq(id)) + .set(( + works::composer.eq(data.composer), + works::title.eq(data.title.clone()), + )) + .execute(conn)?; + + insert_work_data(conn, id, data)?; + + Ok(()) + })?; + + Ok(()) +} + +/// Helper method to populate tables related to a work. +fn insert_work_data(conn: &DbConn, id: i64, data: &WorkInsertion) -> Result<()> { + for instrument in &data.instruments { + diesel::insert_into(instrumentations::table) + .values(Instrumentation { + id: rand::random(), + work: id, + instrument: *instrument, + }) + .execute(conn)?; + } + + for (index, part) in data.parts.iter().enumerate() { + let part = WorkPart { + id: rand::random(), + work: id, + part_index: index.try_into()?, + title: part.title.clone(), + composer: part.composer, + }; + + diesel::insert_into(work_parts::table) + .values(part) + .execute(conn)?; + } + + for section in &data.sections { + let section = WorkSection { + id: rand::random(), + work: id, + title: section.title.clone(), + before_index: section.before_index, + }; + + diesel::insert_into(work_sections::table) + .values(section) + .execute(conn)?; + } + + Ok(()) +} + +/// Get an already existing work without related rows from other tables. +fn get_work(conn: &DbConn, id: u32) -> Result> { + Ok(works::table + .filter(works::id.eq(id as i64)) + .load::(conn)? + .first() + .cloned()) +} + +/// Retrieve all available information on a work from related tables. +fn get_description_for_work(conn: &DbConn, work: &Work) -> Result { + let mut instruments: Vec = Vec::new(); + + let instrumentations = instrumentations::table + .filter(instrumentations::work.eq(work.id)) + .load::(conn)?; + + for instrumentation in instrumentations { + instruments.push( + instruments::table + .filter(instruments::id.eq(instrumentation.instrument)) + .load::(conn)? + .first() + .cloned() + .ok_or(anyhow!( + "No instrument with ID: {}", + instrumentation.instrument + ))?, + ); + } + + let mut part_descriptions: Vec = Vec::new(); + + let work_parts = work_parts::table + .filter(work_parts::work.eq(work.id)) + .load::(conn)?; + + for work_part in work_parts { + part_descriptions.push(WorkPartDescription { + title: work_part.title, + composer: match work_part.composer { + Some(composer) => Some( + persons::table + .filter(persons::id.eq(composer)) + .load::(conn)? + .first() + .cloned() + .ok_or(anyhow!("No person with ID: {}", composer))?, + ), + None => None, + }, + }); + } + + let mut section_descriptions: Vec = Vec::new(); + + let sections = work_sections::table + .filter(work_sections::work.eq(work.id)) + .load::(conn)?; + + for section in sections { + section_descriptions.push(WorkSectionDescription { + title: section.title, + before_index: section.before_index, + }); + } + + let person_id = work.composer.try_into()?; + let person = + get_person(conn, person_id)?.ok_or(anyhow!("Person doesn't exist: {}", person_id))?; + + Ok(WorkDescription { + id: work.id, + composer: person, + title: work.title.clone(), + instruments, + parts: part_descriptions, + sections: section_descriptions, + }) +} + +/// Get an existing work and all available information from related tables. +pub fn get_work_description(conn: &DbConn, id: u32) -> Result> { + let work_description = match get_work(conn, id)? { + Some(work) => Some(get_description_for_work(conn, &work)?), + None => None, + }; + + Ok(work_description) +} + +/// 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 it +pub fn delete_work(conn: &DbConn, id: u32) -> Result<()> { + diesel::delete(works::table.filter(works::id.eq(id as i64))).execute(conn)?; + Ok(()) +} + +/// Get all existing works by a composer and related information from other tables. +pub fn get_work_descriptions(conn: &DbConn, composer_id: u32) -> Result> { + let mut work_descriptions: Vec = Vec::new(); + + let works = works::table + .filter(works::composer.eq(composer_id as i64)) + .load::(conn)?; + + for work in works { + work_descriptions.push(get_description_for_work(conn, &work)?); + } + + Ok(work_descriptions) +} diff --git a/musicus_server/src/main.rs b/musicus_server/src/main.rs new file mode 100644 index 0000000..d77d001 --- /dev/null +++ b/musicus_server/src/main.rs @@ -0,0 +1,36 @@ +// Required for database/schema.rs +#[macro_use] +extern crate diesel; + +use actix_web::{App, HttpServer}; + +mod database; + +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(post_person) + .service(put_person) + .service(get_persons) + }); + + server.bind("127.0.0.1:8087")?.run().await +} diff --git a/musicus_server/src/routes/auth.rs b/musicus_server/src/routes/auth.rs new file mode 100644 index 0000000..66a83a0 --- /dev/null +++ b/musicus_server/src/routes/auth.rs @@ -0,0 +1,261 @@ +use super::ServerError; +use crate::database; +use crate::database::{DbConn, DbPool, User, UserInsertion}; +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 "MUSICUS_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)) +} + +/// Check whether a token allows the user to create a new item. +pub fn may_create(conn: &DbConn, token: &str) -> Result { + let user = authenticate(conn, token)?; + + let result = if user.is_banned { false } else { false }; + + Ok(result) +} + +/// Check whether a token allows the user to edit an item created by him or somebody else. +pub fn may_edit(conn: &DbConn, token: &str, created_by: &str) -> Result { + let user = authenticate(conn, token)?; + + let result = if user.is_banned { + false + } else if user.username == created_by { + true + } else if user.is_editor { + true + } else { + false + }; + + Ok(result) +} + +/// 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 +/// "MUSICUS_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("MUSICUS_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 "MUSICUS_SECRET" as the secret key. This needs to be set. +fn verify_jwt(token: &str) -> Result { + let secret = std::env::var("MUSICUS_SECRET")?; + + let jwt = jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(&secret.as_bytes()), + &jsonwebtoken::Validation::default(), + )?; + + Ok(jwt.claims) +} diff --git a/musicus_server/src/routes/ensembles.rs b/musicus_server/src/routes/ensembles.rs new file mode 100644 index 0000000..e69de29 diff --git a/musicus_server/src/routes/error.rs b/musicus_server/src/routes/error.rs new file mode 100644 index 0000000..7a7019a --- /dev/null +++ b/musicus_server/src/routes/error.rs @@ -0,0 +1,47 @@ +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(error: r2d2::Error) -> Self { + ServerError::Internal + } +} + +impl From for ServerError { + fn from(error: anyhow::Error) -> Self { + 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/musicus_server/src/routes/instruments.rs b/musicus_server/src/routes/instruments.rs new file mode 100644 index 0000000..e69de29 diff --git a/musicus_server/src/routes/mod.rs b/musicus_server/src/routes/mod.rs new file mode 100644 index 0000000..4caf6ef --- /dev/null +++ b/musicus_server/src/routes/mod.rs @@ -0,0 +1,20 @@ +pub mod auth; +pub use auth::*; + +pub mod ensembles; +pub use ensembles::*; + +pub mod error; +pub use error::*; + +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/musicus_server/src/routes/persons.rs b/musicus_server/src/routes/persons.rs new file mode 100644 index 0000000..96eb5c1 --- /dev/null +++ b/musicus_server/src/routes/persons.rs @@ -0,0 +1,103 @@ +use super::{authenticate, ServerError}; +use crate::database; +use crate::database::{DbPool, PersonInsertion}; +use actix_web::{delete, get, post, put, 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 person = 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(person)) +} + +/// Add a new person. The user must be authorized to do that. +#[post("/persons")] +pub async fn post_person( + auth: BearerAuth, + db: web::Data, + data: web::Json, +) -> Result { + let id = rand::random(); + + web::block(move || { + let conn = db.into_inner().get()?; + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + database::insert_person(&conn, id, &data.into_inner(), &user.username)?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().body(id.to_string())) +} + +#[put("/persons/{id}")] +pub async fn put_person( + auth: BearerAuth, + db: web::Data, + id: web::Path, + data: web::Json, +) -> Result { + web::block(move || { + let conn = db.into_inner().get()?; + + let user = authenticate(&conn, auth.token()).or(Err(ServerError::Unauthorized))?; + + let id = id.into_inner(); + let old_person = database::get_person(&conn, id)?.ok_or(ServerError::NotFound)?; + + if user.username != old_person.created_by { + Err(ServerError::Forbidden)?; + } + + database::update_person(&conn, id, &data.into_inner())?; + + Ok(()) + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/persons")] +pub async fn get_persons(db: web::Data) -> Result { + let persons = web::block(move || { + let conn = db.into_inner().get()?; + Ok(database::get_persons(&conn)?) + }) + .await?; + + Ok(HttpResponse::Ok().json(persons)) +} + +#[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))?; + + if user.is_editor { + database::delete_person(&conn, id.into_inner())?; + Ok(()) + } else { + Err(ServerError::Forbidden) + } + }) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/musicus_server/src/routes/recordings.rs b/musicus_server/src/routes/recordings.rs new file mode 100644 index 0000000..e69de29 diff --git a/musicus_server/src/routes/works.rs b/musicus_server/src/routes/works.rs new file mode 100644 index 0000000..e69de29