V1 Server implementation

This commit is contained in:
Agatha Lovelace 2022-09-22 23:31:46 +02:00
commit ca92edef42
Signed by: sorceress
GPG Key ID: 11BBCFC65FC9F401
15 changed files with 3895 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
settings.toml
eleanor-server.db

3043
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

30
Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "eleanor-server"
version = "0.1.0"
edition = "2021"
authors = ["Agatha Lovelace <agatha@technogothic.net>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adler = "1.0.2"
argon2 = "0.4.1"
axum = "0.5.15"
axum-auth = "0.3.0"
clap = { version = "3.2.20", features = ["cargo"] }
lofty = "0.7.3"
miette = { version = "5.2.0", features = ["fancy"] }
mime = "0.3.16"
mime_guess = "2.0.4"
paris = { version = "1.5.13", features = ["macros"] }
rand_core = { version = "0.6.3", features = ["std"] }
rmp-serde = "1.1.0"
sea-orm = { version = "0.9.1", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"] }
sea-orm-migration = "^0.9.0"
sea-query = "0.26.2"
serde = { version = "1.0.142", features = ["derive"] }
symphonia = { version = "0.5.1", features = ["flac", "mp3", "vorbis", "ogg", "wav"] }
tokio = { version = "1.20.1", features = ["full"] }
toml = "0.5.9"
tower = "0.4.13"
walkdir = "2.3.2"

50
src/config.rs Normal file
View File

@ -0,0 +1,50 @@
use std::{fs::File, io::Write};
use miette::{IntoDiagnostic, Result};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct Source {
pub id: u8,
pub path: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
pub port: u16,
pub sources: Vec<Source>,
}
impl Config {
pub fn read_config() -> Result<Self> {
let file = std::env::current_dir()
.and_then(|v| Ok(v.join("settings.toml")))
.into_diagnostic()?;
let contents = std::fs::read_to_string(file).into_diagnostic()?;
toml::from_str(&contents).into_diagnostic()
}
pub fn write_config(config: &Config) -> Result<()> {
let contents = toml::to_string(config).into_diagnostic()?;
let path = std::env::current_dir()
.and_then(|v| Ok(v.join("settings.toml")))
.into_diagnostic()?;
File::create(path)
.and_then(|mut v| v.write_all(contents.as_bytes()))
.into_diagnostic()
}
}
impl Default for Config {
fn default() -> Self {
Config {
port: 8008,
sources: vec![Source {
id: 0,
path: "".into(),
}],
}
}
}

190
src/fetching.rs Normal file
View File

@ -0,0 +1,190 @@
use std::{
ffi::{OsStr, OsString},
fs::File,
hash::Hasher,
path::Path,
};
use super::config::{Config, Source};
use super::model::library;
use adler::Adler32;
use lofty::{read_from_path, Accessor, AudioFile};
use miette::{miette, IntoDiagnostic, Result};
use paris::{success, warn};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set};
use symphonia::{
core::{
io::MediaSourceStream,
meta::{Limit, MetadataOptions},
probe::Hint,
},
default::get_probe,
};
use walkdir::WalkDir;
#[derive(PartialEq, Debug)]
pub enum IndexMode {
Purge,
New,
Initial,
}
pub async fn index_source(source: Source, mode: IndexMode, db: &DatabaseConnection) -> Result<()> {
let mut existing: Vec<OsString> = vec![];
// Force reindex source
if mode == IndexMode::Purge {
warn!("Overwriting source {}", source.id);
library::Entity::delete_many()
.filter(library::Column::SourceId.eq(source.id))
.exec(db)
.await
.into_diagnostic()?;
// Only index new songs
} else if mode == IndexMode::New {
existing = library::Entity::find()
.filter(library::Column::SourceId.eq(source.id))
.column(library::Column::Filename)
.all(db)
.await
.into_diagnostic()?
.into_iter()
.map(|v| v.filename.into())
.collect();
}
for file in WalkDir::new(source.path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| !e.file_type().is_dir())
.filter(|e| {
mime_guess::from_path(e.path())
.first()
.and_then(|v| Some(v.type_() == mime::AUDIO))
.unwrap_or(false)
})
{
if mode == IndexMode::New {
if existing.contains(&file.file_name().into()) {
continue;
};
}
let audio = read_from_path(file.path(), true).into_diagnostic()?;
let tags = audio.first_tag();
let properties = audio.properties();
let hash = hash_file(file.path())?;
let song: library::ActiveModel = library::ActiveModel {
path: Set(file
.path()
.parent()
.and_then(Path::to_str)
.ok_or(miette!("Couldn't get path for file {:?}", file))?
.to_string()),
filename: Set(file
.file_name()
.to_str()
.ok_or(miette!("Couldn't get filename for file {:?}", file))?
.to_string()),
source_id: Set(source.id.into()),
hash: Set(hash.try_into().into_diagnostic()?),
artist: Set(tags
.and_then(|t| t.artist())
.and_then(|t| Some(t.to_string()))),
name: Set(tags
.and_then(|t| t.title())
.and_then(|t| Some(t.to_string()))),
album: Set(tags
.and_then(|t| t.album())
.and_then(|t| Some(t.to_string()))),
genres: Set(tags
.and_then(|t| t.genre())
.and_then(|t| Some(t.to_string()))),
track: Set(tags.and_then(|t| t.track()).and_then(|t| Some(t as i32))),
year: Set(tags.and_then(|t| t.year()).and_then(|t| Some(t as i32))),
duration: Set(properties
.duration()
.as_millis()
.try_into()
.into_diagnostic()?),
..Default::default()
};
library::Entity::insert(song)
.on_conflict(
sea_query::OnConflict::column(library::Column::Filename)
.do_nothing()
.to_owned(),
)
.exec(db)
.await
.into_diagnostic()?;
}
success!("Indexed source {} in {:?} mode", source.id, mode);
Ok(())
}
pub async fn index_initial(db: &DatabaseConnection) -> Result<()> {
let sources = Config::read_config()?.sources;
for source in sources {
index_source(source, IndexMode::Initial, db).await?;
}
Ok(())
}
pub async fn index_new(db: &DatabaseConnection) -> Result<()> {
let sources = Config::read_config()?.sources;
for source in sources {
index_source(source, IndexMode::New, db).await?;
}
Ok(())
}
pub async fn reindex(db: &DatabaseConnection) -> Result<()> {
let sources = Config::read_config()?.sources;
for source in sources {
index_source(source, IndexMode::Purge, db).await?;
}
Ok(())
}
fn hash_file(path: &Path) -> Result<u64> {
let file = Box::new(File::open(path).into_diagnostic()?);
let probe = get_probe();
let ext = path.extension().and_then(OsStr::to_str).unwrap_or("");
let source = MediaSourceStream::new(file, Default::default());
let mut data = probe
.format(
&Hint::new().with_extension(ext),
source,
&Default::default(),
&MetadataOptions {
limit_metadata_bytes: Limit::Maximum(0),
limit_visual_bytes: Limit::Maximum(0),
},
)
.into_diagnostic()?
.format;
let mut adler = Adler32::new();
while let Ok(packet) = data.next_packet() {
adler.write(&packet.data);
}
Ok(adler.finish())
}

128
src/main.rs Normal file
View File

@ -0,0 +1,128 @@
use std::process;
use clap::{arg, command, Command};
use config::Config;
use miette::{ensure, miette, IntoDiagnostic, Result};
use paris::info;
use sea_orm::{Database, DatabaseConnection};
use sea_orm_migration::SchemaManager;
use server::{add_user, remove_user, routes};
use {
fetching::{index_initial, index_new},
utils::{create_app_data, is_first_run, prepare_db},
};
mod config;
mod fetching;
mod migrator;
mod model;
mod server;
mod utils;
#[tokio::main]
async fn main() -> Result<()> {
let matches = command!()
.version("0.1")
.author("Agatha V. Lovelace")
.subcommand(
Command::new("user")
.about("Manage users")
.subcommand_required(true)
.subcommand(
Command::new("add")
.about("Add a user")
.arg(arg!(<USERNAME>))
.arg(arg!(<PASSWORD>)),
)
.subcommand(
Command::new("remove")
.about("Remove a user")
.arg(arg!(<USERNAME>)),
),
)
.get_matches();
// First, make sure that the app's files exist
let first_run = is_first_run()?;
if first_run {
info!("No previous configuration found; Starting first run process");
create_app_data()?;
}
// Create a database connection
let db: DatabaseConnection = Database::connect(&format!(
"sqlite://{}/eleanor-server.db?mode=rwc",
std::env::current_dir().into_diagnostic()?.display()
))
.await
.into_diagnostic()?;
// Run migrations
prepare_db(&db).await?;
let schema_manager = SchemaManager::new(&db);
ensure!(
schema_manager
.has_table("library")
.await
.into_diagnostic()?,
miette!("Running migrations failed")
);
// Handle user management
match matches.subcommand() {
Some(("user", args)) => {
match args.subcommand() {
Some(("add", args)) => {
add_user(
&db,
args.get_one::<String>("USERNAME")
.ok_or(miette!("No username provided"))?
.to_string(),
args.get_one::<String>("PASSWORD")
.ok_or(miette!("No password provided"))?
.to_string(),
)
.await?;
}
Some(("remove", args)) => {
remove_user(
&db,
args.get_one::<String>("USERNAME")
.ok_or(miette!("No username provided"))?
.to_string(),
)
.await?;
}
_ => (),
}
process::exit(0);
}
_ => (),
}
if first_run {
index_initial(&db).await?;
} else {
// Index only new songs
index_new(&db).await?;
}
let config = Config::read_config()?;
// Start the server
info!("Serving on port {}", config.port);
axum::Server::bind(
&format!("0.0.0.0:{}", config.port)
.parse()
.into_diagnostic()?,
)
.serve(routes(db).into_make_service())
.await
.into_diagnostic()?;
Ok(())
}

View File

@ -0,0 +1,71 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Song::Table)
.if_not_exists()
.col(
ColumnDef::new(Song::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Song::Path).string().not_null())
.col(
ColumnDef::new(Song::Filename)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Song::SourceId).integer().not_null())
.col(ColumnDef::new(Song::Hash).integer().not_null().unique_key())
.col(ColumnDef::new(Song::Artist).string())
.col(ColumnDef::new(Song::Name).string())
.col(ColumnDef::new(Song::Album).string())
.col(ColumnDef::new(Song::Duration).integer().not_null())
.col(ColumnDef::new(Song::Genres).string())
.col(ColumnDef::new(Song::Track).integer())
.col(ColumnDef::new(Song::Year).integer())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Song::Table).to_owned())
.await
}
}
/// A Table containing every indexed song
#[derive(Iden)]
pub enum Song {
#[iden = "library"]
Table,
/// Id of the song
Id,
Path,
Filename,
/// Refers to the sources defined in the configuration file and determines if file is remote
SourceId,
/// A hash of the song's samples as Vec<f32>
Hash,
Artist,
Name,
Album,
Duration,
/// Comma separated list of genres
Genres,
/// Number of the track in the album
Track,
Year,
}

View File

@ -0,0 +1,42 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(User::Name).string().not_null().unique_key())
.col(ColumnDef::new(User::Password).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(Iden)]
pub enum User {
#[iden = "users"]
Table,
Id,
Name,
Password,
}

16
src/migrator/mod.rs Normal file
View File

@ -0,0 +1,16 @@
use sea_orm_migration::prelude::*;
mod m20220815_000001_create_library;
mod m20229094_000001_create_users;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220815_000001_create_library::Migration),
Box::new(m20229094_000001_create_users::Migration),
]
}
}

33
src/model/library.rs Normal file
View File

@ -0,0 +1,33 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "library")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub path: String,
pub filename: String,
pub source_id: i32,
pub hash: u32,
pub artist: Option<String>,
pub name: Option<String>,
pub album: Option<String>,
pub duration: u32,
pub genres: Option<String>,
pub track: Option<i32>,
pub year: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

6
src/model/mod.rs Normal file
View File

@ -0,0 +1,6 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
pub mod prelude;
pub mod library;
pub mod users;

4
src/model/prelude.rs Normal file
View File

@ -0,0 +1,4 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
pub use super::library::Entity as Library;
pub use super::users::Entity as Users;

24
src/model/users.rs Normal file
View File

@ -0,0 +1,24 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.9.1
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub password: String,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

203
src/server.rs Normal file
View File

@ -0,0 +1,203 @@
use crate::{
model::{library, users},
utils::path_from_hash,
};
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use axum::{
extract::{FromRequest, Path, RequestParts},
http::{
header::{self, HeaderName},
Request, StatusCode,
},
middleware::{self, Next},
response::Response,
routing::get,
Extension, Router,
};
use axum_auth::AuthBasic;
use miette::{miette, IntoDiagnostic};
use paris::success;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set};
use tower::ServiceBuilder;
pub fn routes(db: DatabaseConnection) -> Router {
Router::new()
.route("/", get(list_library).post(reindex))
.route("/:hash", get(get_stream))
.route("/:hash/cover", get(get_album_art))
.layer(
ServiceBuilder::new()
.layer(Extension(db))
.layer(middleware::from_fn(auth)),
)
}
async fn list_library(
Extension(ref db): Extension<DatabaseConnection>,
) -> Result<Vec<u8>, StatusCode> {
let library = library::Entity::find()
.all(db)
.await
.ok()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let result = rmp_serde::to_vec(&library).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(result)
}
async fn reindex(Extension(ref db): Extension<DatabaseConnection>) -> Result<(), StatusCode> {
super::fetching::reindex(&db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn get_stream(
Path(hash): Path<u32>,
Extension(ref db): Extension<DatabaseConnection>,
) -> Result<([(HeaderName, String); 1], Vec<u8>), StatusCode> {
let path = path_from_hash(&db, hash)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let data = std::fs::read(&path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mime = mime_guess::from_path(&path)
.first()
.map(|v| v.to_string())
.unwrap_or("application/octet-stream".into());
Ok(([(header::CONTENT_TYPE, mime)], data))
}
async fn get_album_art(
Path(hash): Path<u32>,
Extension(ref db): Extension<DatabaseConnection>,
) -> Result<([(HeaderName, String); 1], Vec<u8>), StatusCode> {
let path = path_from_hash(&db, hash)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let tagged_file = lofty::read_from_path(path, false).map_err(|_| StatusCode::NOT_FOUND)?;
let tags = tagged_file
.primary_tag()
.unwrap_or(tagged_file.first_tag().ok_or(StatusCode::NOT_FOUND)?);
let picture = tags.pictures().get(0).ok_or(StatusCode::NOT_FOUND)?;
Ok((
[(header::CONTENT_TYPE, picture.mime_type().to_string())],
picture.data().to_vec(),
))
}
pub async fn add_user(
db: &DatabaseConnection,
username: String,
password: String,
) -> miette::Result<()> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|err| return miette!("Couldn't hash password: {}", err.to_string()))?
.to_string();
let user = users::ActiveModel {
name: Set(username.clone()),
password: Set(hash),
..Default::default()
};
users::Entity::insert(user)
.on_conflict(
sea_query::OnConflict::column(users::Column::Name)
.do_nothing()
.to_owned(),
)
.exec(db)
.await
.into_diagnostic()?;
success!("User {username} added");
Ok(())
}
pub async fn remove_user(db: &DatabaseConnection, username: String) -> miette::Result<()> {
let user = users::Entity::find()
.filter(users::Column::Name.eq(username.clone()))
.one(db)
.await
.into_diagnostic()?;
user.ok_or(miette!("User {} not found", username))?
.delete(db)
.await
.into_diagnostic()?;
success!("User {username} removed");
Ok(())
}
fn verify_password(password: String, hash: String) -> miette::Result<bool> {
let hash = PasswordHash::new(&hash)
.map_err(|err| return miette!("Couldn't parse password hash: {}", err.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok())
}
async fn authenticate(
db: &DatabaseConnection,
AuthBasic((username, password)): AuthBasic,
) -> Result<(), StatusCode> {
let user = users::Entity::find()
.filter(users::Column::Name.eq(username))
.one(db)
.await
.ok()
.flatten()
.ok_or(StatusCode::UNAUTHORIZED)?;
// Compare the provided password with the password hash stored in the database
let authorized = verify_password(password.ok_or(StatusCode::UNAUTHORIZED)?, user.password)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if authorized {
Ok(())
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
async fn auth<B: std::marker::Send>(
req: Request<B>,
next: Next<B>,
) -> Result<Response, StatusCode> {
let mut req = RequestParts::new(req);
let auth = AuthBasic::from_request(&mut req).await.map_err(|e| e.0)?;
let db: &DatabaseConnection = req
.extensions()
.get()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
if let Err(error) = authenticate(db, auth.to_owned()).await {
Err(error)
} else {
let req = req
.try_into_request()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let res = next.run(req).await;
Ok(res)
}
}

52
src/utils.rs Normal file
View File

@ -0,0 +1,52 @@
use miette::{miette, IntoDiagnostic, Result};
use std::fs::File;
use crate::{config::Config, migrator::Migrator, model::library};
use paris::success;
use sea_orm_migration::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
/// If no files have been created in the current directory, the app is running for the first time
pub fn is_first_run() -> Result<bool> {
let path = std::env::current_dir()
.and_then(|v| Ok(v.join("settings.toml")))
.into_diagnostic()?;
Ok(!path.exists())
}
/// Create the necessary files on first run
pub fn create_app_data() -> Result<()> {
let path = std::env::current_dir().into_diagnostic()?;
File::create(&path.join("eleanor-server.db")).into_diagnostic()?;
Config::write_config(&Default::default())?;
success!("Created configuration file");
Ok(())
}
/// Run unapplied migrations
pub async fn prepare_db(db: &sea_orm::DatabaseConnection) -> Result<()> {
Migrator::up(db, None).await.into_diagnostic()?;
success!("Applied migrations");
Ok(())
}
/// Returns a file path from the audio hash
pub async fn path_from_hash(db: &sea_orm::DatabaseConnection, hash: u32) -> Result<String> {
let track = library::Entity::find()
.filter(library::Column::Hash.eq(hash))
.one(db)
.await
.ok()
.flatten()
.ok_or(miette!("Track not found"))?;
let path = format!("{}/{}", track.path, track.filename);
Ok(path)
}