feat: we can now log in.

This commit is contained in:
Lys 2023-10-08 22:53:42 +03:00
parent 281e98c2c8
commit 667fcca4e9
Signed by: lyssieth
GPG key ID: C9CF3D614FAA3940
12 changed files with 979 additions and 209 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/target /target
users.db*
.en*

786
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ publish = ["crates-io"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
cfg-if = "1.0.0"
color-eyre = "0.6.2" color-eyre = "0.6.2"
poem = { version = "1.3.58", features = [ poem = { version = "1.3.58", features = [
"compression", "compression",
@ -15,15 +16,11 @@ poem = { version = "1.3.58", features = [
"static-files", "static-files",
"xml", "xml",
] } ] }
poem-openapi = { version = "3.0.5", features = [
"time",
"openapi-explorer",
"url",
"static-files",
] }
quick-xml = { version = "0.30.0", features = ["serialize"] } quick-xml = { version = "0.30.0", features = ["serialize"] }
serde = { version = "1.0.188", features = ["derive"] } serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.107" serde_json = "1.0.107"
sqlx = { version = "0.7.2", features = ["time", "sqlite", "runtime-tokio"] }
time = { version = "0.3.29", features = ["serde-human-readable", "macros", "parsing"] }
tokio = { version = "1.32.0", features = ["full"] } tokio = { version = "1.32.0", features = ["full"] }
tracing = { version = "0.1.37", features = ["async-await"] } tracing = { version = "0.1.37", features = ["async-await"] }
tracing-subscriber = { version = "0.3.17", features = [ tracing-subscriber = { version = "0.3.17", features = [

View file

@ -13,6 +13,8 @@ RUN rm -rfv /volume/src/*.rs
COPY . . COPY . .
RUN rm -rfv /volume/target/*/release/rave RUN rm -rfv /volume/target/*/release/rave
ENV DATABASE_URL='sqlite:/config/users.db'
RUN cargo build --release --features docker RUN cargo build --release --features docker
RUN mv target/*-unknown-linux-musl/release/rave /tmp/rave RUN mv target/*-unknown-linux-musl/release/rave /tmp/rave
@ -22,7 +24,7 @@ FROM gcr.io/distroless/static AS runtime
COPY --from=build /tmp/rave /rave COPY --from=build /tmp/rave /rave
VOLUME [ "/storage", "/config", "/cache" ] VOLUME [ "/storage", "/config" ]
ENV RUST_LOG=info ENV RUST_LOG=info

5
build.rs Normal file
View file

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View file

@ -0,0 +1 @@
-- Add migration script here

View file

@ -1,7 +1,10 @@
use std::{collections::HashMap, str::FromStr, string::ToString}; use std::{collections::HashMap, fmt::Display, str::FromStr, string::ToString};
use color_eyre::Report; use color_eyre::Report;
use poem::{http::StatusCode, Error, FromRequest, Request, RequestBody, Result}; use poem::{Error, FromRequest, IntoResponse, Request, RequestBody, Result};
use tracing::debug;
use crate::subsonic::{self, SubsonicResponse};
mod de; mod de;
@ -10,7 +13,12 @@ impl<'a> FromRequest<'a> for Authentication {
async fn from_request(req: &'a Request, _: &mut RequestBody) -> Result<Self> { async fn from_request(req: &'a Request, _: &mut RequestBody) -> Result<Self> {
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
if query.is_empty() { if query.is_empty() {
return Err(Error::from_string("Empty query", StatusCode::BAD_REQUEST)); return Err(Error::from_response(
SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
"please provide a `u` parameter".to_string(),
)))
.into_response(),
));
} }
let query = url_escape::decode(query); let query = url_escape::decode(query);
@ -20,75 +28,97 @@ impl<'a> FromRequest<'a> for Authentication {
.filter_map(|q| q.split_once('=')) .filter_map(|q| q.split_once('='))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
debug!("Query: {query:?}");
let user = { let user = {
let user = query.get("u").map(ToString::to_string); let user = query.get("u").map(ToString::to_string);
if user.is_none() { if user.is_none() {
return Err(Error::from_string( return Err(Error::from_response(
"Missing username", SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
StatusCode::BAD_REQUEST, "please provide a `u` parameter".to_string(),
)))
.into_response(),
)); ));
} }
user.expect("Missing username") user.expect("Missing username")
}; };
debug!("User: {user}");
let password = query.get("p").map(ToString::to_string); let password = query.get("p").map(ToString::to_string);
if password.is_some() { if password.is_some() {
return Err(Error::from_string( return Err(Error::from_response(
"Password authentication is not supported", SubsonicResponse::new_error(subsonic::Error::Generic(Some(
StatusCode::BAD_REQUEST, "password authentication is not supported".to_string(),
)))
.into_response(),
)); ));
} }
debug!("Password: {password:?}");
let token = query.get("t").map(ToString::to_string); let token = query.get("t").map(ToString::to_string);
let salt = query.get("s").map(ToString::to_string); let salt = query.get("s").map(ToString::to_string);
if token.is_none() || salt.is_none() { if token.is_none() || salt.is_none() {
return Err(Error::from_string( return Err(Error::from_response(
"Missing token or salt", SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
StatusCode::BAD_REQUEST, "please provide both `t` and `s` parameters".to_string(),
)))
.into_response(),
)); ));
} }
let token = token.expect("Missing token"); let token = token.expect("Missing token");
debug!("Token: {token}");
let salt = salt.expect("Missing salt"); let salt = salt.expect("Missing salt");
debug!("Salt: {salt}");
let version = { let version = {
let version = query.get("v").map(ToString::to_string); let version = query.get("v").map(ToString::to_string);
if version.is_none() { if version.is_none() {
return Err(Error::from_string( return Err(Error::from_response(
"Missing version", SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
StatusCode::BAD_REQUEST, "please provide a `v` parameter".to_string(),
)))
.into_response(),
)); ));
} }
version version
.expect("Missing version") .expect("Missing version")
.parse::<VersionTriple>() .parse::<VersionTriple>()
.map_err(|e| { .map_err(|e| {
Error::from_string(format!("Invalid version: {e}"), StatusCode::BAD_REQUEST) Error::from_response(
SubsonicResponse::new_error(subsonic::Error::Generic(Some(format!(
"invalid version parameter: {e}"
))))
.into_response(),
)
}) })
}?; }?;
if version < VersionTriple(1, 13, 0) { debug!("Version: {version}");
return Err(Error::from_string(
"Unsupported version. We only support 1.13.0 and above",
StatusCode::BAD_REQUEST,
));
}
let client = { let client = {
let client = query.get("c").map(ToString::to_string); let client = query.get("c").map(ToString::to_string);
if client.is_none() { if client.is_none() {
return Err(Error::from_string( return Err(Error::from_response(
"Missing client", SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
StatusCode::BAD_REQUEST, "please provide a `c` parameter".to_string(),
)))
.into_response(),
)); ));
} }
client.expect("Missing client") client.expect("Missing client")
}; };
debug!("Client: {client}");
let format = query let format = query
.get("f") .get("f")
.map_or_else(|| "xml".to_string(), ToString::to_string); .map_or_else(|| "xml".to_string(), ToString::to_string);
debug!("Format: {format}");
Ok(Self { Ok(Self {
username: user, username: user,
token, token,
@ -113,6 +143,12 @@ pub struct Authentication {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VersionTriple(pub u32, pub u32, pub u32); pub struct VersionTriple(pub u32, pub u32, pub u32);
impl Display for VersionTriple {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.0, self.1, self.2)
}
}
impl PartialOrd for VersionTriple { impl PartialOrd for VersionTriple {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other)) Some(self.cmp(other))

View file

@ -1,21 +1,26 @@
#![warn(clippy::pedantic, clippy::nursery)] #![warn(clippy::pedantic, clippy::nursery)]
#![deny(clippy::unwrap_used, clippy::panic)] #![deny(clippy::unwrap_used, clippy::panic)]
#![allow(clippy::module_name_repetitions, clippy::too_many_lines)]
use std::time::Duration; use std::time::Duration;
use authentication::Authentication;
use color_eyre::Result; use color_eyre::Result;
use poem::{ use poem::{
get, get,
listener::TcpListener, listener::TcpListener,
middleware, middleware,
web::{CompressionAlgo, CompressionLevel}, web::{CompressionAlgo, CompressionLevel},
EndpointExt, Route, Endpoint, EndpointExt, Route,
}; };
use rest::build;
use sqlx::SqlitePool;
use tracing::info; use tracing::info;
use tracing_subscriber::{fmt, EnvFilter}; use tracing_subscriber::{fmt, EnvFilter};
mod authentication; mod authentication;
mod rest;
mod subsonic;
mod user;
const LISTEN: &str = "0.0.0.0:1234"; const LISTEN: &str = "0.0.0.0:1234";
@ -24,11 +29,39 @@ async fn main() -> Result<()> {
color_eyre::install()?; color_eyre::install()?;
install_tracing()?; install_tracing()?;
let listener = TcpListener::bind(LISTEN); let route = create_route();
let route = Route::new() let pool = create_pool().await;
sqlx::migrate!().run(&pool).await?;
let route = route.with(middleware::AddData::new(pool));
let server = create_server();
let signal_waiter = || async {
let _ = tokio::signal::ctrl_c().await;
};
let server =
server.run_with_graceful_shutdown(route, signal_waiter(), Some(Duration::from_secs(5)));
server.await?;
Ok(())
}
async fn create_pool() -> SqlitePool {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL not set");
SqlitePool::connect(&url)
.await
.expect("Failed to connect to database")
}
fn create_route() -> Box<dyn Endpoint<Output = poem::Response>> {
Route::new()
.at("/", get(hello_world)) .at("/", get(hello_world))
.at("/auth", get(auth_test)) .nest("/rest", build())
.with(middleware::CatchPanic::new()) .with(middleware::CatchPanic::new())
.with( .with(
middleware::Compression::new() middleware::Compression::new()
@ -39,29 +72,34 @@ async fn main() -> Result<()> {
.with(middleware::CookieJarManager::new()) .with(middleware::CookieJarManager::new())
.with(middleware::NormalizePath::new( .with(middleware::NormalizePath::new(
middleware::TrailingSlash::Trim, middleware::TrailingSlash::Trim,
)); ))
.boxed()
}
fn create_server() -> poem::Server<TcpListener<&'static str>, std::convert::Infallible> {
let listener = TcpListener::bind(LISTEN);
info!("Listening on http://{LISTEN}"); info!("Listening on http://{LISTEN}");
let signal_waiter = || async { poem::Server::new(listener).name("rave")
let _ = tokio::signal::ctrl_c().await;
};
let server = poem::Server::new(listener)
.name("rave")
.run_with_graceful_shutdown(route, signal_waiter(), Some(Duration::from_secs(5)));
server.await?;
Ok(())
} }
fn install_tracing() -> Result<()> { fn install_tracing() -> Result<()> {
let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "warn,rave=debug".to_string()); let filter = {
cfg_if::cfg_if! {
if #[cfg(debug_assertions)] {
std::env::var("RUST_LOG").unwrap_or_else(|_| "poem=trace,rave=debug".to_string())
} else {
std::env::var("RUST_LOG").unwrap_or_else(|_| "poem=warn,rave=debug".to_string())
}
}
};
let filter = EnvFilter::from(filter);
fmt() fmt()
.pretty() .pretty()
.with_env_filter(EnvFilter::from(filter)) .with_env_filter(filter)
.try_init() .try_init()
.map_err(|v| color_eyre::eyre::eyre!("failed to install tracing: {v}"))?; .map_err(|v| color_eyre::eyre::eyre!("failed to install tracing: {v}"))?;
@ -73,8 +111,38 @@ const fn hello_world() -> &'static str {
"Hello, world!" "Hello, world!"
} }
#[allow(clippy::needless_pass_by_value)] #[cfg(test)]
#[poem::handler] mod tests {
fn auth_test(auth: Authentication) -> String { use super::*;
format!("{auth:?}")
use poem::{
http::{Method, StatusCode},
Request,
};
#[tokio::test]
async fn test_hello_world() {
let app = create_route();
let resp = app
.call(Request::builder().method(Method::GET).uri_str("/").finish())
.await;
assert!(
resp.is_ok(),
"Failed to get response: {}",
resp.expect_err("Failed to get response")
);
let resp = resp.expect("Failed to get response");
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().into_string().await;
assert!(
body.is_ok(),
"Failed to get body: {}",
body.expect_err("Failed to get body")
);
let body = body.expect("Failed to get body");
assert_eq!(body, "Hello, world!");
}
} }

7
src/rest.rs Normal file
View file

@ -0,0 +1,7 @@
use poem::{Endpoint, EndpointExt, Route};
mod ping;
pub fn build() -> Box<dyn Endpoint<Output = poem::Response>> {
Route::new().at("/ping", ping::ping).boxed()
}

8
src/rest/ping.rs Normal file
View file

@ -0,0 +1,8 @@
use crate::{authentication::Authentication, subsonic::SubsonicResponse};
#[poem::handler]
pub fn ping(auth: Authentication) -> SubsonicResponse {
dbg!(auth);
SubsonicResponse::new_empty()
}

155
src/subsonic.rs Normal file
View file

@ -0,0 +1,155 @@
#![allow(dead_code)] // TODO: Remove this
use poem::{http::StatusCode, IntoResponse, Response};
use serde::{ser::SerializeStruct, Serialize};
use crate::authentication::VersionTriple;
impl IntoResponse for SubsonicResponse {
fn into_response(self) -> poem::Response {
let body = quick_xml::se::to_string(&self).expect("Failed to serialize response body");
Response::builder().status(StatusCode::OK).body(body)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SubsonicResponse {
#[serde(rename = "@xmlns")]
pub xmlns: String,
#[serde(rename = "@status")]
pub status: ResponseStatus,
#[serde(rename = "@version")]
pub version: VersionTriple,
#[serde(rename = "$value")]
pub inner: SubResponseType,
}
impl SubsonicResponse {
pub fn new(inner: SubResponseType) -> Self {
Self {
xmlns: "http://subsonic.org/restapi".to_string(),
status: ResponseStatus::Ok,
version: VersionTriple(1, 16, 1),
inner,
}
}
pub fn new_empty() -> Self {
Self {
xmlns: "http://subsonic.org/restapi".to_string(),
status: ResponseStatus::Ok,
version: VersionTriple(1, 16, 1),
inner: SubResponseType::Empty,
}
}
pub fn new_error(inner: Error) -> Self {
Self {
xmlns: "http://subsonic.org/restapi".to_string(),
status: ResponseStatus::Failed,
version: VersionTriple(1, 16, 1),
inner: SubResponseType::Error(inner),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum SubResponseType {
#[serde(rename = "musicFolders")]
MusicFolders(MusicFolders),
#[serde(rename = "error")]
Error(Error),
Empty,
}
#[derive(Debug, Clone, Serialize)]
pub struct MusicFolders {}
#[derive(Debug, Clone, Copy)]
pub enum ResponseStatus {
Ok,
Failed,
}
impl Serialize for ResponseStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(match self {
Self::Ok => "ok",
Self::Failed => "failed",
})
}
}
#[derive(Debug, Clone)]
pub enum Error {
Generic(Option<String>),
RequiredParameterMissing(Option<String>),
IncompatibleClientVersion(Option<String>),
IncompatibleServerVersion(Option<String>),
WrongUsernameOrPassword(Option<String>),
TokenAuthenticationNotSupportedForLDAP(Option<String>),
UserIsNotAuthorizedForGivenOperation(Option<String>),
TrialPeriodExpired(Option<String>),
RequestedDataWasNotFound(Option<String>),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut error = serializer.serialize_struct("error", 2)?;
error.serialize_field("@code", &self.code())?;
error.serialize_field("@message", &self.message())?;
error.end()
}
}
impl Error {
pub const fn code(&self) -> i32 {
match self {
Self::Generic(_) => 0,
Self::RequiredParameterMissing(_) => 10,
Self::IncompatibleClientVersion(_) => 20,
Self::IncompatibleServerVersion(_) => 30,
Self::WrongUsernameOrPassword(_) => 40,
Self::TokenAuthenticationNotSupportedForLDAP(_) => 41,
Self::UserIsNotAuthorizedForGivenOperation(_) => 50,
Self::TrialPeriodExpired(_) => 60,
Self::RequestedDataWasNotFound(_) => 70,
}
}
pub fn message(&self) -> String {
match self {
Self::Generic(inner) => inner.clone().unwrap_or_else(|| "Generic error".to_string()),
Self::RequiredParameterMissing(inner) => inner
.clone()
.unwrap_or_else(|| "Required parameter missing".to_string()),
Self::IncompatibleClientVersion(inner) => inner
.clone()
.unwrap_or_else(|| "Incompatible client version".to_string()),
Self::IncompatibleServerVersion(inner) => inner
.clone()
.unwrap_or_else(|| "Incompatible server version".to_string()),
Self::WrongUsernameOrPassword(inner) => inner
.clone()
.unwrap_or_else(|| "Wrong username or password".to_string()),
Self::TokenAuthenticationNotSupportedForLDAP(inner) => inner
.clone()
.unwrap_or_else(|| "Token authentication not supported for LDAP".to_string()),
Self::UserIsNotAuthorizedForGivenOperation(inner) => inner
.clone()
.unwrap_or_else(|| "User is not authorized for given operation".to_string()),
Self::TrialPeriodExpired(inner) => inner
.clone()
.unwrap_or_else(|| "Trial period expired".to_string()),
Self::RequestedDataWasNotFound(inner) => inner
.clone()
.unwrap_or_else(|| "Requested data was not found".to_string()),
}
}
}

11
src/user.rs Normal file
View file

@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct User {
pub id: i32,
pub created_at: time::OffsetDateTime,
pub name: String,
/// I hate this. It's stored in plaintext. Why?
pub password: String,
pub is_admin: bool,
}