Compare commits
10 Commits
3726d637f5
...
792efb0c9f
Author | SHA1 | Date | |
---|---|---|---|
792efb0c9f | |||
aea3ff86f6 | |||
7d475cd1ac | |||
906e52dd57 | |||
cd94bb08dc | |||
a1c09cd404 | |||
aea515c047 | |||
3a9508e8b6 | |||
59b0e29f0d | |||
c2425bd4a4 |
25
.env.example
Normal file
25
.env.example
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# set this to serve HTTP, otherwise you have to specify TLS cert and key
|
||||||
|
MUSIKQUAD_INSECURE=1
|
||||||
|
|
||||||
|
# paths to certificate and key files (in PEM format) for serving HTTPS
|
||||||
|
MUSIKQUAD_TLS_CERT_PATH="./cert.pem"
|
||||||
|
MUSIKQUAD_TLS_KEY_PATH="./key.pem"
|
||||||
|
|
||||||
|
# the port the public APIs will be served on
|
||||||
|
MUSIKQUAD_PORT=5505
|
||||||
|
# the port the internal APIs will be served on
|
||||||
|
MUSIKQUAD_INTERNAL_PORT=5506
|
||||||
|
# where the tokens will be read from / written to
|
||||||
|
MUSIKQUAD_TOKENS_FILE=./tokens.txt
|
||||||
|
# how many seconds to wait before revoking scoped tokens
|
||||||
|
# note: scoped tokens are not persisted, so they will be gone if the server is shutdown
|
||||||
|
MUSIKQUAD_SCOPED_EXPIRY_DURATION=3600
|
||||||
|
|
||||||
|
# address of where the musikcubed server is located
|
||||||
|
MUSIKQUAD_MUSIKCUBED_ADDRESS=localhost
|
||||||
|
# metadata port of the musikcubed server
|
||||||
|
MUSIKQUAD_MUSIKCUBED_METADATA_PORT=7905
|
||||||
|
# http data port of the musikcubed server
|
||||||
|
MUSIKQUAD_MUSIKCUBED_HTTP_PORT=7906
|
||||||
|
# the password of the musikcubed server
|
||||||
|
MUSIKQUAD_MUSIKCUBED_PASSWORD=""
|
26
README.md
26
README.md
@ -1,3 +1,27 @@
|
|||||||
# musikquadrupled
|
# musikquadrupled
|
||||||
|
|
||||||
A proxy server for musikcubed that implements TLS, alternative authentication solution and CORS for proper Web support. Compatible with musikcube.
|
A proxy server for musikcubed that implements TLS, alternative authentication solution, CORS for proper Web support and a few extra goodies. Compatible with musikcube.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The configuration is done via `MUSIKQUAD_*` environment variables. Alternatively you can also provide a `.env` file in the same directory as the binary or a parent directory, which will be readed and loaded as environment variables.
|
||||||
|
|
||||||
|
See `.env.example` for a commented `.env` file.
|
||||||
|
|
||||||
|
### API (public)
|
||||||
|
|
||||||
|
All of the `musikcubed` APIs are implemented. On top of those:
|
||||||
|
|
||||||
|
- `share/generate/:external_id`: Returns a "scoped token" for the specified ID of a music. This allows one to access `share/*` APIs. This endpoint requires authentication.
|
||||||
|
- `share/audio/:scoped_token`: Returns the music (or a range of it if `RANGE` header is present) corresponding to the specified `scoped_token`. Returns `404 Not Found` if the requested music isn't available.
|
||||||
|
- `share/info/:scoped_token`: Returns information (title, album, artist etc.) of the music corresponding to the specified `scoped_token` in JSON. Returns `404 Not Found` if the requested music isn't available.
|
||||||
|
- `share/thumbnail/:scoped_token`: Returns the thumbnail for the music corresponding to the specified `scoped_token`. Returns `404 Not Found` if the requested thumbnail isn't available.
|
||||||
|
|
||||||
|
If an endpoint requires authentication this means you have to provide a token via a `token` query paramater in the URL. If not provided `401 Unauthorized` will be returned. For `musikcube`, this is not necessary as the server will also handle `Authentication` header for the APIs `musikcube` uses.
|
||||||
|
|
||||||
|
### API (internal)
|
||||||
|
|
||||||
|
These are only exposed to `localhost` clients.
|
||||||
|
|
||||||
|
- `token/generate`: Generates a new token for use with the `token` query paramater in the public APIs.
|
||||||
|
- `token/revoke_all`: Revokes all tokens.
|
67
src/handlers/data.rs
Normal file
67
src/handlers/data.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
use axum::extract::{Query, State};
|
||||||
|
use http::{
|
||||||
|
header::{AUTHORIZATION, CACHE_CONTROL, RANGE},
|
||||||
|
Request, Response,
|
||||||
|
};
|
||||||
|
use hyper::Body;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
utils::{extract_password_from_basic_auth, remove_token_from_query},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Auth, AUDIO_CACHE_HEADER};
|
||||||
|
|
||||||
|
pub(crate) async fn get_music(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Query(query): Query<Auth>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
http(State(app), Query(query), req).await.map(|mut resp| {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
// add cache header
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(CACHE_CONTROL, AUDIO_CACHE_HEADER.clone());
|
||||||
|
}
|
||||||
|
resp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn http(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Query(auth): Query<Auth>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let maybe_token = auth.token.or_else(|| {
|
||||||
|
req.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(|auth| extract_password_from_basic_auth(auth).ok())
|
||||||
|
});
|
||||||
|
app.verify_token(maybe_token).await?;
|
||||||
|
|
||||||
|
// remove token from query
|
||||||
|
let path = req.uri().path();
|
||||||
|
let query_map = remove_token_from_query(req.uri().query());
|
||||||
|
let has_query = !query_map.is_empty();
|
||||||
|
let query = has_query
|
||||||
|
.then(|| serde_qs::to_string(&query_map).unwrap())
|
||||||
|
.unwrap_or_else(String::new);
|
||||||
|
let query_prefix = has_query.then_some("?").unwrap_or("");
|
||||||
|
|
||||||
|
let mut request = Request::new(Body::empty());
|
||||||
|
if let Some(range) = req.headers().get(RANGE).cloned() {
|
||||||
|
request.headers_mut().insert(RANGE, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"proxying request to {}:{} with headers {:?}",
|
||||||
|
app.musikcubed_address,
|
||||||
|
app.musikcubed_http_port,
|
||||||
|
request.headers()
|
||||||
|
);
|
||||||
|
|
||||||
|
app.make_musikcubed_request(format!("{path}{query_prefix}{query}"), request)
|
||||||
|
.await
|
||||||
|
}
|
28
src/handlers/internal.rs
Normal file
28
src/handlers/internal.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use axum::{extract::State, response::IntoResponse};
|
||||||
|
use http::StatusCode;
|
||||||
|
|
||||||
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
|
pub(crate) async fn revoke_all_tokens(State(app): State<AppState>) -> impl IntoResponse {
|
||||||
|
app.tokens.revoke_all().await;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = app.tokens.write(&app.tokens_path).await {
|
||||||
|
tracing::error!("couldn't write tokens file: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn generate_token(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
// generate token
|
||||||
|
let token = app.tokens.generate().await?;
|
||||||
|
// start task to write tokens
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = app.tokens.write(&app.tokens_path).await {
|
||||||
|
tracing::error!("couldn't write tokens file: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(token.into_response())
|
||||||
|
}
|
@ -1,323 +1,28 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::AppError;
|
|
||||||
use async_tungstenite::{
|
use async_tungstenite::{
|
||||||
tokio::TokioAdapter, tungstenite::Message as TungsteniteMessage, WebSocketStream,
|
tokio::TokioAdapter, tungstenite::Message as TungsteniteMessage, WebSocketStream,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade},
|
ws::{Message as AxumMessage, WebSocket},
|
||||||
Path, Query, State,
|
State, WebSocketUpgrade,
|
||||||
},
|
},
|
||||||
headers::UserAgent,
|
headers::UserAgent,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
TypedHeader,
|
||||||
Router, TypedHeader,
|
|
||||||
};
|
};
|
||||||
use base64::Engine;
|
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use http::{
|
|
||||||
header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, RANGE},
|
|
||||||
HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode,
|
|
||||||
};
|
|
||||||
use hyper::Body;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tower_http::{
|
|
||||||
cors::CorsLayer,
|
|
||||||
request_id::{MakeRequestUuid, SetRequestIdLayer},
|
|
||||||
sensitive_headers::SetSensitiveRequestHeadersLayer,
|
|
||||||
trace::TraceLayer,
|
|
||||||
};
|
|
||||||
use tracing::{Instrument, Span};
|
use tracing::{Instrument, Span};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::WsApiMessage,
|
api::WsApiMessage,
|
||||||
utils::{axum_msg_to_tungstenite, tungstenite_msg_to_axum, QueryDisplay, WsError},
|
error::AppError,
|
||||||
AppState, B64,
|
utils::{axum_msg_to_tungstenite, tungstenite_msg_to_axum, WsError},
|
||||||
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIO_CACHE_HEADER: HeaderValue = HeaderValue::from_static("private, max-age=604800");
|
pub(crate) async fn metadata_ws(
|
||||||
const REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Auth {
|
|
||||||
#[serde(default)]
|
|
||||||
token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_password_from_basic_auth(auth: &str) -> Result<String, AppError> {
|
|
||||||
let decoded = B64.decode(auth.trim_start_matches("Basic "))?;
|
|
||||||
let auth = String::from_utf8(decoded)?;
|
|
||||||
Ok(auth.trim_start_matches("default:").to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_token_from_query(query: Option<&str>) -> HashMap<String, String> {
|
|
||||||
let mut query_map: HashMap<String, String> = query
|
|
||||||
.and_then(|v| serde_qs::from_str(v).ok())
|
|
||||||
.unwrap_or_else(HashMap::new);
|
|
||||||
query_map.remove("token");
|
|
||||||
query_map
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_span_trace<B>(req: &Request<B>) -> Span {
|
|
||||||
let query_map = remove_token_from_query(req.uri().query());
|
|
||||||
|
|
||||||
let request_id = req
|
|
||||||
.headers()
|
|
||||||
.get(REQUEST_ID)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("no id set");
|
|
||||||
|
|
||||||
if query_map.is_empty() {
|
|
||||||
tracing::debug_span!(
|
|
||||||
"request",
|
|
||||||
path = %req.uri().path(),
|
|
||||||
id = %request_id,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let query_display = QueryDisplay::new(query_map);
|
|
||||||
tracing::debug_span!(
|
|
||||||
"request",
|
|
||||||
path = %req.uri().path(),
|
|
||||||
query = %query_display,
|
|
||||||
id = %request_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn handler(state: AppState) -> Result<(Router, Router), AppError> {
|
|
||||||
let internal_router = Router::new()
|
|
||||||
.route("/token/generate", get(generate_token))
|
|
||||||
.route("/token/revoke_all", post(revoke_all_tokens))
|
|
||||||
.with_state(state.clone());
|
|
||||||
|
|
||||||
let trace_layer = TraceLayer::new_for_http()
|
|
||||||
.make_span_with(make_span_trace)
|
|
||||||
.on_request(|req: &Request<Body>, _span: &Span| {
|
|
||||||
tracing::debug!(
|
|
||||||
"started processing request {} on {:?}",
|
|
||||||
req.method(),
|
|
||||||
req.version(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let cors_layer = CorsLayer::new()
|
|
||||||
.allow_origin(tower_http::cors::Any)
|
|
||||||
.allow_headers([CONTENT_TYPE, CACHE_CONTROL, REQUEST_ID])
|
|
||||||
.allow_methods([Method::GET]);
|
|
||||||
let sensitive_header_layer = SetSensitiveRequestHeadersLayer::new([AUTHORIZATION]);
|
|
||||||
let request_id_layer = SetRequestIdLayer::new(REQUEST_ID.clone(), MakeRequestUuid);
|
|
||||||
|
|
||||||
let router = Router::new()
|
|
||||||
.route("/token/generate_for_music/:id", get(generate_scoped_token))
|
|
||||||
.route("/thumbnail/:id", get(http))
|
|
||||||
.route("/audio/external_id/:id", get(get_music))
|
|
||||||
.route("/share/audio/:token", get(get_scoped_music_file))
|
|
||||||
.route("/share/thumbnail/:token", get(get_scoped_music_thumbnail))
|
|
||||||
.route("/share/info/:token", get(get_scoped_music_info))
|
|
||||||
.route("/", get(metadata_ws))
|
|
||||||
.layer(trace_layer)
|
|
||||||
.layer(sensitive_header_layer)
|
|
||||||
.layer(cors_layer)
|
|
||||||
.layer(request_id_layer)
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
Ok((router, internal_router))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn revoke_all_tokens(State(app): State<AppState>) -> impl IntoResponse {
|
|
||||||
app.tokens.revoke_all().await;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(err) = app.tokens.write(&app.tokens_path).await {
|
|
||||||
tracing::error!("couldn't write tokens file: {err}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
StatusCode::OK
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_token(State(app): State<AppState>) -> Result<axum::response::Response, AppError> {
|
|
||||||
// generate token
|
|
||||||
let token = app.tokens.generate().await?;
|
|
||||||
// start task to write tokens
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(err) = app.tokens.write(&app.tokens_path).await {
|
|
||||||
tracing::error!("couldn't write tokens file: {err}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(token.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_scoped_token(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Query(query): Query<Auth>,
|
|
||||||
Path(music_id): Path<String>,
|
|
||||||
) -> Result<axum::response::Response, AppError> {
|
|
||||||
let maybe_token = query.token;
|
|
||||||
|
|
||||||
'ok: {
|
|
||||||
if let Some(token) = maybe_token {
|
|
||||||
if app.tokens.verify(token).await? {
|
|
||||||
tracing::debug!("verified token");
|
|
||||||
break 'ok;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::debug!("invalid token");
|
|
||||||
return Ok((
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"Invalid token or token not present",
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate token
|
|
||||||
let token = app.scoped_tokens.generate_for_id(music_id).await;
|
|
||||||
Ok(token.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_scoped_music_info(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Path(token): Path<String>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
let music_id = app.verify_scoped_token(token).await?;
|
|
||||||
let Some(info) = app.music_info.get(music_id).await else {
|
|
||||||
return Err("music id not found".into());
|
|
||||||
};
|
|
||||||
Ok(serde_json::to_string(&info).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_scoped_music_thumbnail(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Path(token): Path<String>,
|
|
||||||
) -> Result<Response<Body>, AppError> {
|
|
||||||
let music_id = app.verify_scoped_token(token).await?;
|
|
||||||
let Some(info) = app.music_info.get(music_id).await else {
|
|
||||||
return Err("music id not found".into());
|
|
||||||
};
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri(format!(
|
|
||||||
"http://{}:{}/thumbnail/{}",
|
|
||||||
app.musikcubed_address, app.musikcubed_http_port, info.thumbnail_id
|
|
||||||
))
|
|
||||||
.header(AUTHORIZATION, app.musikcubed_auth_header_value.clone())
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("cant fail");
|
|
||||||
let resp = app.client.request(req).await?;
|
|
||||||
Ok(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_scoped_music_file(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Path(token): Path<String>,
|
|
||||||
request: Request<Body>,
|
|
||||||
) -> Result<Response<Body>, AppError> {
|
|
||||||
let music_id = app.verify_scoped_token(token).await?;
|
|
||||||
let mut req = Request::builder()
|
|
||||||
.uri(format!(
|
|
||||||
"http://{}:{}/audio/external_id/{}",
|
|
||||||
app.musikcubed_address, app.musikcubed_http_port, music_id
|
|
||||||
))
|
|
||||||
.header(AUTHORIZATION, app.musikcubed_auth_header_value.clone())
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("cant fail");
|
|
||||||
// proxy any range headers
|
|
||||||
if let Some(range) = request.headers().get(RANGE).cloned() {
|
|
||||||
req.headers_mut().insert(RANGE, range);
|
|
||||||
}
|
|
||||||
let mut resp = app.client.request(req).await?;
|
|
||||||
if resp.status().is_success() {
|
|
||||||
// add cache header
|
|
||||||
resp.headers_mut()
|
|
||||||
.insert(CACHE_CONTROL, AUDIO_CACHE_HEADER.clone());
|
|
||||||
}
|
|
||||||
Ok(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_music(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Query(query): Query<Auth>,
|
|
||||||
req: Request<Body>,
|
|
||||||
) -> Result<Response<Body>, AppError> {
|
|
||||||
http(State(app), Query(query), req).await.map(|mut resp| {
|
|
||||||
if resp.status().is_success() {
|
|
||||||
// add cache header
|
|
||||||
resp.headers_mut()
|
|
||||||
.insert(CACHE_CONTROL, AUDIO_CACHE_HEADER.clone());
|
|
||||||
}
|
|
||||||
resp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn http(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Query(auth): Query<Auth>,
|
|
||||||
mut req: Request<Body>,
|
|
||||||
) -> Result<Response<Body>, AppError> {
|
|
||||||
// remove token from query
|
|
||||||
let path = req.uri().path();
|
|
||||||
let query_map = remove_token_from_query(req.uri().query());
|
|
||||||
let has_query = !query_map.is_empty();
|
|
||||||
let query = has_query
|
|
||||||
.then(|| serde_qs::to_string(&query_map).unwrap())
|
|
||||||
.unwrap_or_else(String::new);
|
|
||||||
let query_prefix = has_query.then_some("?").unwrap_or("");
|
|
||||||
|
|
||||||
// craft new url
|
|
||||||
*req.uri_mut() = format!(
|
|
||||||
"http://{}:{}{path}{query_prefix}{query}",
|
|
||||||
app.musikcubed_address, app.musikcubed_http_port
|
|
||||||
)
|
|
||||||
.parse()?;
|
|
||||||
|
|
||||||
let maybe_token = auth.token.or_else(|| {
|
|
||||||
req.headers()
|
|
||||||
.get(AUTHORIZATION)
|
|
||||||
.and_then(|h| h.to_str().ok())
|
|
||||||
.and_then(|auth| extract_password_from_basic_auth(auth).ok())
|
|
||||||
});
|
|
||||||
|
|
||||||
'ok: {
|
|
||||||
if let Some(token) = maybe_token {
|
|
||||||
if app.tokens.verify(token).await? {
|
|
||||||
tracing::debug!("verified token");
|
|
||||||
break 'ok;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::debug!("invalid token");
|
|
||||||
return Ok(Response::builder()
|
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
|
||||||
.body("Invalid token or token not present".to_string().into())
|
|
||||||
.expect("cant fail"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxy only the headers we need
|
|
||||||
let headers = {
|
|
||||||
let mut headers = HeaderMap::with_capacity(2);
|
|
||||||
let mut proxy_header = |header_name: HeaderName| {
|
|
||||||
if let Some(value) = req.headers().get(&header_name).cloned() {
|
|
||||||
headers.insert(header_name, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// proxy range header
|
|
||||||
proxy_header(RANGE);
|
|
||||||
// add auth
|
|
||||||
headers.insert(AUTHORIZATION, app.musikcubed_auth_header_value.clone());
|
|
||||||
headers
|
|
||||||
};
|
|
||||||
|
|
||||||
*req.headers_mut() = headers;
|
|
||||||
|
|
||||||
let scheme = req.uri().scheme_str().unwrap();
|
|
||||||
let authority = req.uri().authority().unwrap().as_str();
|
|
||||||
tracing::debug!(
|
|
||||||
"proxying request to {scheme}://{authority} with headers {:?}",
|
|
||||||
req.headers()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(app.client.request(req).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn metadata_ws(
|
|
||||||
State(app): State<AppState>,
|
State(app): State<AppState>,
|
||||||
TypedHeader(user_agent): TypedHeader<UserAgent>,
|
TypedHeader(user_agent): TypedHeader<UserAgent>,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
@ -352,7 +57,7 @@ async fn metadata_ws(
|
|||||||
Ok(upgrade)
|
Ok(upgrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_metadata_socket(
|
pub(crate) async fn handle_metadata_socket(
|
||||||
mut server_socket: WebSocketStream<TokioAdapter<TcpStream>>,
|
mut server_socket: WebSocketStream<TokioAdapter<TcpStream>>,
|
||||||
mut client_socket: WebSocket,
|
mut client_socket: WebSocket,
|
||||||
app: AppState,
|
app: AppState,
|
21
src/handlers/mod.rs
Normal file
21
src/handlers/mod.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use ::http::HeaderValue;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub(crate) mod data;
|
||||||
|
pub(crate) mod internal;
|
||||||
|
pub(crate) mod metadata;
|
||||||
|
pub(crate) mod share;
|
||||||
|
|
||||||
|
pub(crate) const AUDIO_CACHE_HEADER: HeaderValue =
|
||||||
|
HeaderValue::from_static("private, max-age=604800");
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct Auth {
|
||||||
|
#[serde(default)]
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use self::data::*;
|
||||||
|
pub(crate) use internal::*;
|
||||||
|
pub(crate) use metadata::*;
|
||||||
|
pub(crate) use share::*;
|
73
src/handlers/share.rs
Normal file
73
src/handlers/share.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use http::{
|
||||||
|
header::{CACHE_CONTROL, RANGE},
|
||||||
|
Request, Response,
|
||||||
|
};
|
||||||
|
use hyper::Body;
|
||||||
|
|
||||||
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
|
use super::{Auth, AUDIO_CACHE_HEADER};
|
||||||
|
|
||||||
|
pub(crate) async fn generate_scoped_token(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Query(query): Query<Auth>,
|
||||||
|
Path(music_id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
app.verify_token(query.token).await?;
|
||||||
|
|
||||||
|
// generate token
|
||||||
|
let token = app.scoped_tokens.generate_for_id(music_id).await;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_scoped_music_info(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let music_id = app.verify_scoped_token(token).await?;
|
||||||
|
let Some(info) = app.music_info.get(music_id).await else {
|
||||||
|
return Err("music id not found".into());
|
||||||
|
};
|
||||||
|
Ok(serde_json::to_string(&info).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_scoped_music_thumbnail(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let music_id = app.verify_scoped_token(token).await?;
|
||||||
|
let Some(info) = app.music_info.get(music_id).await else {
|
||||||
|
return Err("music id not found".into());
|
||||||
|
};
|
||||||
|
app.make_musikcubed_request(
|
||||||
|
format!("/thumbnail/{}", info.thumbnail_id),
|
||||||
|
Request::new(Body::empty()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_scoped_music_file(
|
||||||
|
State(app): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
request: Request<Body>,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let music_id = app.verify_scoped_token(token).await?;
|
||||||
|
let mut req = Request::new(Body::empty());
|
||||||
|
// proxy any range headers
|
||||||
|
if let Some(range) = request.headers().get(RANGE).cloned() {
|
||||||
|
req.headers_mut().insert(RANGE, range);
|
||||||
|
}
|
||||||
|
let mut resp = app
|
||||||
|
.make_musikcubed_request(format!("/audio/external_id/{music_id}"), req)
|
||||||
|
.await?;
|
||||||
|
if resp.status().is_success() {
|
||||||
|
// add cache header
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(CACHE_CONTROL, AUDIO_CACHE_HEADER.clone());
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
182
src/main.rs
182
src/main.rs
@ -1,26 +1,21 @@
|
|||||||
use std::{net::SocketAddr, process::ExitCode, sync::Arc};
|
use std::{net::SocketAddr, process::ExitCode};
|
||||||
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use base64::Engine;
|
|
||||||
use dotenvy::Error as DotenvError;
|
use dotenvy::Error as DotenvError;
|
||||||
use error::AppError;
|
use error::AppError;
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use hyper::{client::HttpConnector, Body};
|
|
||||||
use scc::HashMap;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use token::{MusicScopedTokens, Tokens};
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use tracing_subscriber::prelude::*;
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::WsApiMessage,
|
state::{AppState, AppStateInternal},
|
||||||
utils::{HandleWsItem, WsError},
|
utils::get_conf,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod error;
|
mod error;
|
||||||
mod handler;
|
mod handlers;
|
||||||
|
mod router;
|
||||||
|
mod state;
|
||||||
mod token;
|
mod token;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
@ -60,7 +55,7 @@ async fn app() -> Result<(), AppError> {
|
|||||||
let internal_port: u16 = get_conf("INTERNAL_PORT")?.parse()?;
|
let internal_port: u16 = get_conf("INTERNAL_PORT")?.parse()?;
|
||||||
|
|
||||||
let state = AppState::new(AppStateInternal::new(public_port).await?);
|
let state = AppState::new(AppStateInternal::new(public_port).await?);
|
||||||
let (public_router, internal_router) = handler::handler(state).await?;
|
let (public_router, internal_router) = router::handler(state).await?;
|
||||||
|
|
||||||
let internal_make_service = internal_router.into_make_service();
|
let internal_make_service = internal_router.into_make_service();
|
||||||
let internal_task = tokio::spawn(
|
let internal_task = tokio::spawn(
|
||||||
@ -96,166 +91,3 @@ async fn app() -> Result<(), AppError> {
|
|||||||
.map(|res| res.map_err(AppError::from))
|
.map(|res| res.map_err(AppError::from))
|
||||||
.and_then(std::convert::identity)
|
.and_then(std::convert::identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_conf(key: &str) -> Result<String, AppError> {
|
|
||||||
const ENV_NAMESPACE: &str = "MUSIKQUAD";
|
|
||||||
|
|
||||||
let key = format!("{ENV_NAMESPACE}_{key}");
|
|
||||||
std::env::var(&key).map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client = hyper::Client<HttpConnector, Body>;
|
|
||||||
|
|
||||||
type AppState = Arc<AppStateInternal>;
|
|
||||||
|
|
||||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AppStateInternal {
|
|
||||||
client: Client,
|
|
||||||
tokens: Tokens,
|
|
||||||
music_info: MusicInfoMap,
|
|
||||||
scoped_tokens: MusicScopedTokens,
|
|
||||||
tokens_path: String,
|
|
||||||
public_port: u16,
|
|
||||||
musikcubed_address: String,
|
|
||||||
musikcubed_http_port: u16,
|
|
||||||
musikcubed_metadata_port: u16,
|
|
||||||
musikcubed_password: String,
|
|
||||||
musikcubed_auth_header_value: http::HeaderValue,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppStateInternal {
|
|
||||||
async fn new(public_port: u16) -> Result<Self, AppError> {
|
|
||||||
let musikcubed_http_port = get_conf("MUSIKCUBED_HTTP_PORT")?.parse()?;
|
|
||||||
let musikcubed_metadata_port = get_conf("MUSIKCUBED_METADATA_PORT")?.parse()?;
|
|
||||||
let musikcubed_address = get_conf("MUSIKCUBED_ADDRESS")?;
|
|
||||||
let musikcubed_password = get_conf("MUSIKCUBED_PASSWORD")?;
|
|
||||||
let musikcubed_auth_header_value = {
|
|
||||||
let mut val: http::HeaderValue = format!(
|
|
||||||
"Basic {}",
|
|
||||||
B64.encode(format!("default:{}", musikcubed_password))
|
|
||||||
)
|
|
||||||
.parse()
|
|
||||||
.expect("valid header value");
|
|
||||||
val.set_sensitive(true);
|
|
||||||
val
|
|
||||||
};
|
|
||||||
|
|
||||||
let tokens_path = get_conf("TOKENS_FILE")?;
|
|
||||||
|
|
||||||
let music_info = MusicInfoMap::new();
|
|
||||||
music_info
|
|
||||||
.read(
|
|
||||||
musikcubed_password.clone(),
|
|
||||||
&musikcubed_address,
|
|
||||||
musikcubed_metadata_port,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let this = Self {
|
|
||||||
client: Client::new(),
|
|
||||||
tokens: Tokens::read(&tokens_path).await?,
|
|
||||||
scoped_tokens: MusicScopedTokens::new(get_conf("SCOPED_EXPIRY_DURATION")?.parse()?),
|
|
||||||
musikcubed_address,
|
|
||||||
musikcubed_http_port,
|
|
||||||
musikcubed_metadata_port,
|
|
||||||
musikcubed_auth_header_value,
|
|
||||||
musikcubed_password,
|
|
||||||
tokens_path,
|
|
||||||
public_port,
|
|
||||||
music_info,
|
|
||||||
};
|
|
||||||
Ok(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify_scoped_token(&self, token: impl AsRef<str>) -> Result<String, AppError> {
|
|
||||||
self.scoped_tokens.verify(token).await.ok_or_else(|| {
|
|
||||||
AppError::from("Invalid token or not authorized").status(http::StatusCode::UNAUTHORIZED)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
|
||||||
struct MusicInfo {
|
|
||||||
external_id: String,
|
|
||||||
title: String,
|
|
||||||
album: String,
|
|
||||||
artist: String,
|
|
||||||
thumbnail_id: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct MusicInfoMap {
|
|
||||||
map: HashMap<String, MusicInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MusicInfoMap {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
map: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get(&self, id: impl AsRef<str>) -> Option<MusicInfo> {
|
|
||||||
self.map.read_async(id.as_ref(), |_, v| v.clone()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read(
|
|
||||||
&self,
|
|
||||||
password: impl Into<String>,
|
|
||||||
address: impl AsRef<str>,
|
|
||||||
port: u16,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use async_tungstenite::tungstenite::Message;
|
|
||||||
|
|
||||||
let uri = format!("ws://{}:{}", address.as_ref(), port);
|
|
||||||
let (mut ws_stream, _) = async_tungstenite::tokio::connect_async(uri)
|
|
||||||
.await
|
|
||||||
.map_err(WsError::from)?;
|
|
||||||
|
|
||||||
let device_id = "musikquadrupled";
|
|
||||||
|
|
||||||
// do the authentication
|
|
||||||
let auth_msg = WsApiMessage::authenticate(password.into())
|
|
||||||
.id("auth")
|
|
||||||
.device_id(device_id);
|
|
||||||
ws_stream
|
|
||||||
.send(Message::Text(auth_msg.to_string()))
|
|
||||||
.await
|
|
||||||
.map_err(WsError::from)?;
|
|
||||||
let auth_reply: WsApiMessage = ws_stream.next().await.handle_item()?;
|
|
||||||
let is_authenticated = auth_reply
|
|
||||||
.options
|
|
||||||
.get("authenticated")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !is_authenticated {
|
|
||||||
return Err("not authenticated".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch the tracks
|
|
||||||
let fetch_tracks_msg = WsApiMessage::request("query_tracks")
|
|
||||||
.device_id(device_id)
|
|
||||||
.id("fetch_tracks")
|
|
||||||
.option("limit", u32::MAX)
|
|
||||||
.option("offset", 0);
|
|
||||||
ws_stream
|
|
||||||
.send(Message::Text(fetch_tracks_msg.to_string()))
|
|
||||||
.await
|
|
||||||
.map_err(WsError::from)?;
|
|
||||||
let mut tracks_reply: WsApiMessage = ws_stream.next().await.handle_item()?;
|
|
||||||
let Some(Value::Array(tracks)) = tracks_reply.options.remove("data") else {
|
|
||||||
tracing::debug!("reply: {tracks_reply:#?}");
|
|
||||||
return Err("must have tracks".into());
|
|
||||||
};
|
|
||||||
for track in tracks {
|
|
||||||
let info: MusicInfo = serde_json::from_value(track).unwrap();
|
|
||||||
let _ = self.map.insert_async(info.external_id.clone(), info).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws_stream.close(None).await.map_err(WsError::from)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
93
src/router.rs
Normal file
93
src/router.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
use super::AppError;
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use http::{
|
||||||
|
header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE},
|
||||||
|
HeaderName, Method, Request,
|
||||||
|
};
|
||||||
|
use hyper::Body;
|
||||||
|
use tower_http::{
|
||||||
|
cors::CorsLayer,
|
||||||
|
request_id::{MakeRequestUuid, SetRequestIdLayer},
|
||||||
|
sensitive_headers::SetSensitiveRequestHeadersLayer,
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
use tracing::Span;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
handlers,
|
||||||
|
state::AppState,
|
||||||
|
utils::{remove_token_from_query, QueryDisplay},
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
|
||||||
|
|
||||||
|
fn make_span_trace<B>(req: &Request<B>) -> Span {
|
||||||
|
let query_map = remove_token_from_query(req.uri().query());
|
||||||
|
|
||||||
|
let request_id = req
|
||||||
|
.headers()
|
||||||
|
.get(REQUEST_ID)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("no id set");
|
||||||
|
|
||||||
|
if query_map.is_empty() {
|
||||||
|
tracing::debug_span!(
|
||||||
|
"request",
|
||||||
|
path = %req.uri().path(),
|
||||||
|
id = %request_id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let query_display = QueryDisplay::new(query_map);
|
||||||
|
tracing::debug_span!(
|
||||||
|
"request",
|
||||||
|
path = %req.uri().path(),
|
||||||
|
query = %query_display,
|
||||||
|
id = %request_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handler(state: AppState) -> Result<(Router, Router), AppError> {
|
||||||
|
let internal_router = Router::new()
|
||||||
|
.route("/token/generate", get(handlers::generate_token))
|
||||||
|
.route("/token/revoke_all", post(handlers::revoke_all_tokens))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let trace_layer = TraceLayer::new_for_http()
|
||||||
|
.make_span_with(make_span_trace)
|
||||||
|
.on_request(|req: &Request<Body>, _span: &Span| {
|
||||||
|
tracing::debug!(
|
||||||
|
"started processing request {} on {:?}",
|
||||||
|
req.method(),
|
||||||
|
req.version(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let cors_layer = CorsLayer::new()
|
||||||
|
.allow_origin(tower_http::cors::Any)
|
||||||
|
.allow_headers([CONTENT_TYPE, CACHE_CONTROL, REQUEST_ID])
|
||||||
|
.allow_methods([Method::GET]);
|
||||||
|
let sensitive_header_layer = SetSensitiveRequestHeadersLayer::new([AUTHORIZATION]);
|
||||||
|
let request_id_layer = SetRequestIdLayer::new(REQUEST_ID.clone(), MakeRequestUuid);
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/thumbnail/:id", get(handlers::http))
|
||||||
|
.route("/audio/external_id/:id", get(handlers::get_music))
|
||||||
|
.route("/share/generate/:id", get(handlers::generate_scoped_token))
|
||||||
|
.route("/share/audio/:token", get(handlers::get_scoped_music_file))
|
||||||
|
.route(
|
||||||
|
"/share/thumbnail/:token",
|
||||||
|
get(handlers::get_scoped_music_thumbnail),
|
||||||
|
)
|
||||||
|
.route("/share/info/:token", get(handlers::get_scoped_music_info))
|
||||||
|
.route("/", get(handlers::metadata_ws))
|
||||||
|
.layer(trace_layer)
|
||||||
|
.layer(sensitive_header_layer)
|
||||||
|
.layer(cors_layer)
|
||||||
|
.layer(request_id_layer)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
Ok((router, internal_router))
|
||||||
|
}
|
207
src/state.rs
Normal file
207
src/state.rs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use hyper::{client::HttpConnector, Body};
|
||||||
|
use scc::HashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::WsApiMessage,
|
||||||
|
error::AppError,
|
||||||
|
token::{MusicScopedTokens, Tokens},
|
||||||
|
utils::{get_conf, HandleWsItem, WsError, B64},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Client = hyper::Client<HttpConnector, Body>;
|
||||||
|
|
||||||
|
pub(crate) type AppState = Arc<AppStateInternal>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct AppStateInternal {
|
||||||
|
pub(crate) client: Client,
|
||||||
|
pub(crate) tokens: Tokens,
|
||||||
|
pub(crate) music_info: MusicInfoMap,
|
||||||
|
pub(crate) scoped_tokens: MusicScopedTokens,
|
||||||
|
pub(crate) tokens_path: String,
|
||||||
|
pub(crate) public_port: u16,
|
||||||
|
pub(crate) musikcubed_address: String,
|
||||||
|
pub(crate) musikcubed_http_port: u16,
|
||||||
|
pub(crate) musikcubed_metadata_port: u16,
|
||||||
|
pub(crate) musikcubed_password: String,
|
||||||
|
pub(crate) musikcubed_auth_header_value: http::HeaderValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppStateInternal {
|
||||||
|
pub(crate) async fn new(public_port: u16) -> Result<Self, AppError> {
|
||||||
|
let musikcubed_http_port = get_conf("MUSIKCUBED_HTTP_PORT")?.parse()?;
|
||||||
|
let musikcubed_metadata_port = get_conf("MUSIKCUBED_METADATA_PORT")?.parse()?;
|
||||||
|
let musikcubed_address = get_conf("MUSIKCUBED_ADDRESS")?;
|
||||||
|
let musikcubed_password = get_conf("MUSIKCUBED_PASSWORD")?;
|
||||||
|
let musikcubed_auth_header_value = {
|
||||||
|
let mut val: http::HeaderValue = format!(
|
||||||
|
"Basic {}",
|
||||||
|
B64.encode(format!("default:{}", musikcubed_password))
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.expect("valid header value");
|
||||||
|
val.set_sensitive(true);
|
||||||
|
val
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokens_path = get_conf("TOKENS_FILE")?;
|
||||||
|
|
||||||
|
let music_info = MusicInfoMap::new();
|
||||||
|
music_info
|
||||||
|
.read(
|
||||||
|
musikcubed_password.clone(),
|
||||||
|
&musikcubed_address,
|
||||||
|
musikcubed_metadata_port,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let this = Self {
|
||||||
|
client: Client::new(),
|
||||||
|
tokens: Tokens::read(&tokens_path).await?,
|
||||||
|
scoped_tokens: MusicScopedTokens::new(get_conf("SCOPED_EXPIRY_DURATION")?.parse()?),
|
||||||
|
musikcubed_address,
|
||||||
|
musikcubed_http_port,
|
||||||
|
musikcubed_metadata_port,
|
||||||
|
musikcubed_auth_header_value,
|
||||||
|
musikcubed_password,
|
||||||
|
tokens_path,
|
||||||
|
public_port,
|
||||||
|
music_info,
|
||||||
|
};
|
||||||
|
Ok(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn verify_scoped_token(
|
||||||
|
&self,
|
||||||
|
token: impl AsRef<str>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
self.scoped_tokens.verify(token).await.ok_or_else(|| {
|
||||||
|
AppError::from("Invalid token or not authorized").status(http::StatusCode::UNAUTHORIZED)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn verify_token(
|
||||||
|
&self,
|
||||||
|
maybe_token: Option<impl AsRef<str>>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(token) = maybe_token {
|
||||||
|
if self.tokens.verify(token).await? {
|
||||||
|
tracing::debug!("verified token");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!("invalid token");
|
||||||
|
Err(AppError::from("Invalid token or token not present")
|
||||||
|
.status(http::StatusCode::UNAUTHORIZED))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn make_musikcubed_request(
|
||||||
|
&self,
|
||||||
|
path: impl AsRef<str>,
|
||||||
|
mut req: http::Request<hyper::Body>,
|
||||||
|
) -> Result<http::Response<hyper::Body>, AppError> {
|
||||||
|
*req.uri_mut() = format!(
|
||||||
|
"http://{}:{}{}",
|
||||||
|
self.musikcubed_address,
|
||||||
|
self.musikcubed_http_port,
|
||||||
|
path.as_ref()
|
||||||
|
)
|
||||||
|
.parse()?;
|
||||||
|
req.headers_mut().insert(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
self.musikcubed_auth_header_value.clone(),
|
||||||
|
);
|
||||||
|
let resp = self.client.request(req).await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub(crate) struct MusicInfo {
|
||||||
|
pub(crate) external_id: String,
|
||||||
|
pub(crate) title: String,
|
||||||
|
pub(crate) album: String,
|
||||||
|
pub(crate) artist: String,
|
||||||
|
pub(crate) thumbnail_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct MusicInfoMap {
|
||||||
|
map: HashMap<String, MusicInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicInfoMap {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
map: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get(&self, id: impl AsRef<str>) -> Option<MusicInfo> {
|
||||||
|
self.map.read_async(id.as_ref(), |_, v| v.clone()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
password: impl Into<String>,
|
||||||
|
address: impl AsRef<str>,
|
||||||
|
port: u16,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use async_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
let uri = format!("ws://{}:{}", address.as_ref(), port);
|
||||||
|
let (mut ws_stream, _) = async_tungstenite::tokio::connect_async(uri)
|
||||||
|
.await
|
||||||
|
.map_err(WsError::from)?;
|
||||||
|
|
||||||
|
let device_id = "musikquadrupled";
|
||||||
|
|
||||||
|
// do the authentication
|
||||||
|
let auth_msg = WsApiMessage::authenticate(password.into())
|
||||||
|
.id("auth")
|
||||||
|
.device_id(device_id);
|
||||||
|
ws_stream
|
||||||
|
.send(Message::Text(auth_msg.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(WsError::from)?;
|
||||||
|
let auth_reply: WsApiMessage = ws_stream.next().await.handle_item()?;
|
||||||
|
let is_authenticated = auth_reply
|
||||||
|
.options
|
||||||
|
.get("authenticated")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !is_authenticated {
|
||||||
|
return Err("not authenticated".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch the tracks
|
||||||
|
let fetch_tracks_msg = WsApiMessage::request("query_tracks")
|
||||||
|
.device_id(device_id)
|
||||||
|
.id("fetch_tracks")
|
||||||
|
.option("limit", u32::MAX)
|
||||||
|
.option("offset", 0);
|
||||||
|
ws_stream
|
||||||
|
.send(Message::Text(fetch_tracks_msg.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(WsError::from)?;
|
||||||
|
let mut tracks_reply: WsApiMessage = ws_stream.next().await.handle_item()?;
|
||||||
|
let Some(Value::Array(tracks)) = tracks_reply.options.remove("data") else {
|
||||||
|
tracing::debug!("reply: {tracks_reply:#?}");
|
||||||
|
return Err("must have tracks".into());
|
||||||
|
};
|
||||||
|
for track in tracks {
|
||||||
|
let info: MusicInfo = serde_json::from_value(track).unwrap();
|
||||||
|
let _ = self.map.insert_async(info.external_id.clone(), info).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws_stream.close(None).await.map_err(WsError::from)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@ -32,9 +32,12 @@ pub(crate) struct Tokens {
|
|||||||
|
|
||||||
impl Tokens {
|
impl Tokens {
|
||||||
pub async fn read(path: impl AsRef<Path>) -> Result<Self, AppError> {
|
pub async fn read(path: impl AsRef<Path>) -> Result<Self, AppError> {
|
||||||
|
let tokens = tokio::fs::read_to_string(path).await?;
|
||||||
let this = Self {
|
let this = Self {
|
||||||
hashed: Arc::new(HashSet::new()),
|
hashed: Arc::new(HashSet::new()),
|
||||||
raw_contents: Box::leak(tokio::fs::read_to_string(path).await?.into_boxed_str()),
|
// this is okay since we only call this once and it will be
|
||||||
|
// used for all of it's lifetime
|
||||||
|
raw_contents: Box::leak(tokens.into_boxed_str()),
|
||||||
};
|
};
|
||||||
|
|
||||||
for token in this.raw_contents.lines() {
|
for token in this.raw_contents.lines() {
|
||||||
@ -118,7 +121,7 @@ impl MusicScopedTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct MusicScopedToken {
|
struct MusicScopedToken {
|
||||||
creation: u64,
|
creation: u64,
|
||||||
music_id: String,
|
music_id: String,
|
||||||
}
|
}
|
||||||
|
26
src/utils.rs
26
src/utils.rs
@ -8,6 +8,11 @@ use axum::{
|
|||||||
extract::ws::{CloseFrame as AxumCloseFrame, Message as AxumMessage},
|
extract::ws::{CloseFrame as AxumCloseFrame, Message as AxumMessage},
|
||||||
Error as AxumError,
|
Error as AxumError,
|
||||||
};
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub(crate) const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum WsError {
|
pub(crate) enum WsError {
|
||||||
@ -196,3 +201,24 @@ impl Display for QueryDisplay {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extract_password_from_basic_auth(auth: &str) -> Result<String, AppError> {
|
||||||
|
let decoded = B64.decode(auth.trim_start_matches("Basic "))?;
|
||||||
|
let auth = String::from_utf8(decoded)?;
|
||||||
|
Ok(auth.trim_start_matches("default:").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_token_from_query(query: Option<&str>) -> HashMap<String, String> {
|
||||||
|
let mut query_map: HashMap<String, String> = query
|
||||||
|
.and_then(|v| serde_qs::from_str(v).ok())
|
||||||
|
.unwrap_or_else(HashMap::new);
|
||||||
|
query_map.remove("token");
|
||||||
|
query_map
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_conf(key: &str) -> Result<String, AppError> {
|
||||||
|
const ENV_NAMESPACE: &str = "MUSIKQUAD";
|
||||||
|
|
||||||
|
let key = format!("{ENV_NAMESPACE}_{key}");
|
||||||
|
std::env::var(&key).map_err(Into::into)
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user