Add server

This commit is contained in:
Elias Projahn 2020-11-14 23:08:37 +01:00
parent 775f3ffe90
commit d0c25531d3
27 changed files with 1725 additions and 3 deletions

View file

@ -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

View file

@ -58,3 +58,18 @@ $ diesel migration run --database-url test.sqlite
```
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/.

3
musicus_server/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/.env
/Cargo.lock
/target

19
musicus_server/Cargo.toml Normal file
View file

@ -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"

50
musicus_server/README.md Normal file
View file

@ -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/.

View file

@ -0,0 +1,2 @@
[print_schema]
file = "src/database/schema.rs"

View file

@ -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();

View file

@ -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;

View file

@ -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;

View file

@ -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)
);

View file

@ -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<Option<Ensemble>> {
Ok(ensembles::table
.filter(ensembles::id.eq(id as i64))
.load::<Ensemble>(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<Vec<Ensemble>> {
Ok(ensembles::table.load::<Ensemble>(conn)?)
}

View file

@ -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<Option<Instrument>> {
Ok(instruments::table
.filter(instruments::id.eq(id as i64))
.load::<Instrument>(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<Vec<Instrument>> {
Ok(instruments::table.load::<Instrument>(conn)?)
}

View file

@ -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<r2d2::ConnectionManager<PgConnection>>;
/// One database connection from the connection pool.
pub type DbConn = r2d2::PooledConnection<r2d2::ConnectionManager<PgConnection>>;
/// 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<DbPool> {
let url = std::env::var("MUSICUS_DATABASE_URL")?;
let manager = r2d2::ConnectionManager::<PgConnection>::new(url);
let pool = r2d2::Pool::new(manager)?;
Ok(pool)
}

View file

@ -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<Option<Person>> {
Ok(persons::table
.filter(persons::id.eq(id as i64))
.load::<Person>(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<Vec<Person>> {
Ok(persons::table.load::<Person>(conn)?)
}

View file

@ -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<i64>,
pub ensemble: Option<i64>,
pub role: Option<i64>,
}
/// A structure for collecting all available information on a performance.
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PerformanceDescription {
pub person: Option<Person>,
pub ensemble: Option<Ensemble>,
pub role: Option<Instrument>,
}
/// 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<PerformanceDescription>,
}
/// A structure representing data on a performance.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PerformanceInsertion {
pub person: Option<i64>,
pub ensemble: Option<i64>,
pub role: Option<i64>,
}
/// 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<PerformanceInsertion>,
}
/// 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<Option<Recording>> {
Ok(recordings::table
.filter(recordings::id.eq(id as i64))
.load::<Recording>(conn)?
.first()
.cloned())
}
/// Retrieve all available information on a recording from related tables.
pub fn get_description_for_recording(
conn: &DbConn,
recording: &Recording,
) -> Result<RecordingDescription> {
let mut performance_descriptions: Vec<PerformanceDescription> = Vec::new();
let performances = performances::table
.filter(performances::recording.eq(recording.id))
.load::<Performance>(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::<Person>(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::<Ensemble>(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::<Instrument>(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<Option<RecordingDescription>> {
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<Vec<RecordingDescription>> {
let mut recording_descriptions: Vec<RecordingDescription> = 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::<Recording>(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<Vec<RecordingDescription>> {
let mut recording_descriptions: Vec<RecordingDescription> = 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::<Recording>(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<Vec<RecordingDescription>> {
let mut recording_descriptions: Vec<RecordingDescription> = Vec::new();
let recordings = recordings::table
.filter(recordings::work.eq(work_id as i64))
.load::<Recording>(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(())
}

View file

@ -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<Int8>,
ensemble -> Nullable<Int8>,
role -> Nullable<Int8>,
}
}
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<Text>,
is_admin -> Bool,
is_editor -> Bool,
is_banned -> Bool,
}
}
table! {
work_parts (id) {
id -> Int8,
work -> Int8,
part_index -> Int8,
title -> Text,
composer -> Nullable<Int8>,
}
}
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,
);

View file

@ -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<String>,
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<String>,
}
/// 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<Option<User>> {
Ok(users::table
.filter(users::username.eq(username))
.load::<User>(conn)?
.first()
.cloned())
}

View file

@ -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<i64>,
}
/// 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<Person>,
}
/// 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<Instrument>,
pub parts: Vec<WorkPartDescription>,
pub sections: Vec<WorkSectionDescription>,
}
/// 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<i64>,
}
/// 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<i64>,
pub parts: Vec<WorkPartInsertion>,
pub sections: Vec<WorkSectionInsertion>,
}
/// 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<Option<Work>> {
Ok(works::table
.filter(works::id.eq(id as i64))
.load::<Work>(conn)?
.first()
.cloned())
}
/// Retrieve all available information on a work from related tables.
fn get_description_for_work(conn: &DbConn, work: &Work) -> Result<WorkDescription> {
let mut instruments: Vec<Instrument> = Vec::new();
let instrumentations = instrumentations::table
.filter(instrumentations::work.eq(work.id))
.load::<Instrumentation>(conn)?;
for instrumentation in instrumentations {
instruments.push(
instruments::table
.filter(instruments::id.eq(instrumentation.instrument))
.load::<Instrument>(conn)?
.first()
.cloned()
.ok_or(anyhow!(
"No instrument with ID: {}",
instrumentation.instrument
))?,
);
}
let mut part_descriptions: Vec<WorkPartDescription> = Vec::new();
let work_parts = work_parts::table
.filter(work_parts::work.eq(work.id))
.load::<WorkPart>(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::<Person>(conn)?
.first()
.cloned()
.ok_or(anyhow!("No person with ID: {}", composer))?,
),
None => None,
},
});
}
let mut section_descriptions: Vec<WorkSectionDescription> = Vec::new();
let sections = work_sections::table
.filter(work_sections::work.eq(work.id))
.load::<WorkSection>(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<Option<WorkDescription>> {
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<Vec<WorkDescription>> {
let mut work_descriptions: Vec<WorkDescription> = Vec::new();
let works = works::table
.filter(works::composer.eq(composer_id as i64))
.load::<Work>(conn)?;
for work in works {
work_descriptions.push(get_description_for_work(conn, &work)?);
}
Ok(work_descriptions)
}

View file

@ -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
}

View file

@ -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<String>,
}
/// 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<String>,
pub email: Option<String>,
}
/// Response body data for getting a user.
#[derive(Serialize, Debug, Clone)]
pub struct GetUser {
pub username: String,
pub email: Option<String>,
}
/// 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<DbPool>,
data: web::Json<UserRegistration>,
) -> Result<HttpResponse, ServerError> {
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<DbPool>,
username: web::Path<String>,
data: web::Json<PutUser>,
) -> Result<HttpResponse, ServerError> {
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<DbPool>,
username: web::Path<String>,
auth: BearerAuth,
) -> Result<HttpResponse, ServerError> {
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<DbPool>,
data: web::Json<Login>,
) -> Result<HttpResponse, ServerError> {
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<User> {
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<bool> {
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<bool> {
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<String> {
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<String> {
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<Claims> {
let secret = std::env::var("MUSICUS_SECRET")?;
let jwt = jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(&secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)?;
Ok(jwt.claims)
}

View file

View file

@ -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<r2d2::Error> for ServerError {
fn from(error: r2d2::Error) -> Self {
ServerError::Internal
}
}
impl From<anyhow::Error> for ServerError {
fn from(error: anyhow::Error) -> Self {
ServerError::Internal
}
}
impl From<error::BlockingError<ServerError>> for ServerError {
fn from(error: error::BlockingError<ServerError>) -> Self {
match error {
error::BlockingError::Error(error) => error,
error::BlockingError::Canceled => ServerError::Internal,
}
}
}

View file

View file

@ -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::*;

View file

@ -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<DbPool>,
id: web::Path<u32>,
) -> Result<HttpResponse, ServerError> {
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<DbPool>,
data: web::Json<PersonInsertion>,
) -> Result<HttpResponse, ServerError> {
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<DbPool>,
id: web::Path<u32>,
data: web::Json<PersonInsertion>,
) -> Result<HttpResponse, ServerError> {
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<DbPool>) -> Result<HttpResponse, ServerError> {
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<DbPool>,
id: web::Path<u32>,
) -> Result<HttpResponse, ServerError> {
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())
}

View file

View file