feat: we can now log in.
This commit is contained in:
parent
281e98c2c8
commit
667fcca4e9
12 changed files with 979 additions and 209 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
/target
|
||||
users.db*
|
||||
.en*
|
||||
|
|
|
|||
786
Cargo.lock
generated
786
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ publish = ["crates-io"]
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
cfg-if = "1.0.0"
|
||||
color-eyre = "0.6.2"
|
||||
poem = { version = "1.3.58", features = [
|
||||
"compression",
|
||||
|
|
@ -15,15 +16,11 @@ poem = { version = "1.3.58", features = [
|
|||
"static-files",
|
||||
"xml",
|
||||
] }
|
||||
poem-openapi = { version = "3.0.5", features = [
|
||||
"time",
|
||||
"openapi-explorer",
|
||||
"url",
|
||||
"static-files",
|
||||
] }
|
||||
quick-xml = { version = "0.30.0", features = ["serialize"] }
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
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"] }
|
||||
tracing = { version = "0.1.37", features = ["async-await"] }
|
||||
tracing-subscriber = { version = "0.3.17", features = [
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ RUN rm -rfv /volume/src/*.rs
|
|||
COPY . .
|
||||
|
||||
RUN rm -rfv /volume/target/*/release/rave
|
||||
|
||||
ENV DATABASE_URL='sqlite:/config/users.db'
|
||||
RUN cargo build --release --features docker
|
||||
|
||||
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
|
||||
|
||||
VOLUME [ "/storage", "/config", "/cache" ]
|
||||
VOLUME [ "/storage", "/config" ]
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
|
|
|
|||
5
build.rs
Normal file
5
build.rs
Normal 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");
|
||||
}
|
||||
1
migrations/0001_create-user.sql
Normal file
1
migrations/0001_create-user.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- Add migration script here
|
||||
|
|
@ -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 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;
|
||||
|
||||
|
|
@ -10,7 +13,12 @@ impl<'a> FromRequest<'a> for Authentication {
|
|||
async fn from_request(req: &'a Request, _: &mut RequestBody) -> Result<Self> {
|
||||
let query = req.uri().query().unwrap_or_default();
|
||||
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);
|
||||
|
|
@ -20,75 +28,97 @@ impl<'a> FromRequest<'a> for Authentication {
|
|||
.filter_map(|q| q.split_once('='))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
debug!("Query: {query:?}");
|
||||
|
||||
let user = {
|
||||
let user = query.get("u").map(ToString::to_string);
|
||||
if user.is_none() {
|
||||
return Err(Error::from_string(
|
||||
"Missing username",
|
||||
StatusCode::BAD_REQUEST,
|
||||
return Err(Error::from_response(
|
||||
SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
|
||||
"please provide a `u` parameter".to_string(),
|
||||
)))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
user.expect("Missing username")
|
||||
};
|
||||
|
||||
debug!("User: {user}");
|
||||
|
||||
let password = query.get("p").map(ToString::to_string);
|
||||
if password.is_some() {
|
||||
return Err(Error::from_string(
|
||||
"Password authentication is not supported",
|
||||
StatusCode::BAD_REQUEST,
|
||||
return Err(Error::from_response(
|
||||
SubsonicResponse::new_error(subsonic::Error::Generic(Some(
|
||||
"password authentication is not supported".to_string(),
|
||||
)))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
debug!("Password: {password:?}");
|
||||
|
||||
let token = query.get("t").map(ToString::to_string);
|
||||
let salt = query.get("s").map(ToString::to_string);
|
||||
if token.is_none() || salt.is_none() {
|
||||
return Err(Error::from_string(
|
||||
"Missing token or salt",
|
||||
StatusCode::BAD_REQUEST,
|
||||
return Err(Error::from_response(
|
||||
SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
|
||||
"please provide both `t` and `s` parameters".to_string(),
|
||||
)))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
let token = token.expect("Missing token");
|
||||
debug!("Token: {token}");
|
||||
let salt = salt.expect("Missing salt");
|
||||
debug!("Salt: {salt}");
|
||||
|
||||
let version = {
|
||||
let version = query.get("v").map(ToString::to_string);
|
||||
|
||||
if version.is_none() {
|
||||
return Err(Error::from_string(
|
||||
"Missing version",
|
||||
StatusCode::BAD_REQUEST,
|
||||
return Err(Error::from_response(
|
||||
SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
|
||||
"please provide a `v` parameter".to_string(),
|
||||
)))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
version
|
||||
.expect("Missing version")
|
||||
.parse::<VersionTriple>()
|
||||
.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) {
|
||||
return Err(Error::from_string(
|
||||
"Unsupported version. We only support 1.13.0 and above",
|
||||
StatusCode::BAD_REQUEST,
|
||||
));
|
||||
}
|
||||
debug!("Version: {version}");
|
||||
|
||||
let client = {
|
||||
let client = query.get("c").map(ToString::to_string);
|
||||
|
||||
if client.is_none() {
|
||||
return Err(Error::from_string(
|
||||
"Missing client",
|
||||
StatusCode::BAD_REQUEST,
|
||||
return Err(Error::from_response(
|
||||
SubsonicResponse::new_error(subsonic::Error::RequiredParameterMissing(Some(
|
||||
"please provide a `c` parameter".to_string(),
|
||||
)))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
client.expect("Missing client")
|
||||
};
|
||||
debug!("Client: {client}");
|
||||
|
||||
let format = query
|
||||
.get("f")
|
||||
.map_or_else(|| "xml".to_string(), ToString::to_string);
|
||||
|
||||
debug!("Format: {format}");
|
||||
|
||||
Ok(Self {
|
||||
username: user,
|
||||
token,
|
||||
|
|
@ -113,6 +143,12 @@ pub struct Authentication {
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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 {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
|
|
|
|||
114
src/main.rs
114
src/main.rs
|
|
@ -1,21 +1,26 @@
|
|||
#![warn(clippy::pedantic, clippy::nursery)]
|
||||
#![deny(clippy::unwrap_used, clippy::panic)]
|
||||
#![allow(clippy::module_name_repetitions, clippy::too_many_lines)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use authentication::Authentication;
|
||||
use color_eyre::Result;
|
||||
use poem::{
|
||||
get,
|
||||
listener::TcpListener,
|
||||
middleware,
|
||||
web::{CompressionAlgo, CompressionLevel},
|
||||
EndpointExt, Route,
|
||||
Endpoint, EndpointExt, Route,
|
||||
};
|
||||
use rest::build;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
mod authentication;
|
||||
mod rest;
|
||||
mod subsonic;
|
||||
mod user;
|
||||
|
||||
const LISTEN: &str = "0.0.0.0:1234";
|
||||
|
||||
|
|
@ -24,11 +29,39 @@ async fn main() -> Result<()> {
|
|||
color_eyre::install()?;
|
||||
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("/auth", get(auth_test))
|
||||
.nest("/rest", build())
|
||||
.with(middleware::CatchPanic::new())
|
||||
.with(
|
||||
middleware::Compression::new()
|
||||
|
|
@ -39,29 +72,34 @@ async fn main() -> Result<()> {
|
|||
.with(middleware::CookieJarManager::new())
|
||||
.with(middleware::NormalizePath::new(
|
||||
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}");
|
||||
|
||||
let signal_waiter = || async {
|
||||
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(())
|
||||
poem::Server::new(listener).name("rave")
|
||||
}
|
||||
|
||||
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()
|
||||
.pretty()
|
||||
.with_env_filter(EnvFilter::from(filter))
|
||||
.with_env_filter(filter)
|
||||
.try_init()
|
||||
.map_err(|v| color_eyre::eyre::eyre!("failed to install tracing: {v}"))?;
|
||||
|
||||
|
|
@ -73,8 +111,38 @@ const fn hello_world() -> &'static str {
|
|||
"Hello, world!"
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[poem::handler]
|
||||
fn auth_test(auth: Authentication) -> String {
|
||||
format!("{auth:?}")
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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
7
src/rest.rs
Normal 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
8
src/rest/ping.rs
Normal 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
155
src/subsonic.rs
Normal 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
11
src/user.rs
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue