133 lines
3.8 KiB
Rust
133 lines
3.8 KiB
Rust
use axum::{
|
|
extract::{Query, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Json},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
use crate::security::sanitize_path;
|
|
use super::WebState;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BrowseQuery {
|
|
#[serde(default)]
|
|
pub path: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct BrowseResponse {
|
|
pub path: String,
|
|
pub entries: Vec<Entry>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct Entry {
|
|
pub name: String,
|
|
#[serde(rename = "type")]
|
|
pub kind: EntryKind,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub size: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum EntryKind {
|
|
File,
|
|
Dir,
|
|
}
|
|
|
|
pub async fn handler(
|
|
State(state): State<WebState>,
|
|
Query(query): Query<BrowseQuery>,
|
|
) -> impl IntoResponse {
|
|
let safe = match sanitize_path(&query.path) {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid path"}))).into_response();
|
|
}
|
|
};
|
|
|
|
let dir_path: PathBuf = state.root.join(&safe);
|
|
|
|
let read_dir = match tokio::fs::read_dir(&dir_path).await {
|
|
Ok(rd) => rd,
|
|
Err(e) => {
|
|
let status = if e.kind() == std::io::ErrorKind::NotFound {
|
|
StatusCode::NOT_FOUND
|
|
} else {
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
};
|
|
return (status, Json(serde_json::json!({"error": e.to_string()}))).into_response();
|
|
}
|
|
};
|
|
|
|
let mut entries: Vec<Entry> = Vec::new();
|
|
let mut rd = read_dir;
|
|
|
|
loop {
|
|
match rd.next_entry().await {
|
|
Ok(Some(entry)) => {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
// Skip hidden files
|
|
if name.starts_with('.') {
|
|
continue;
|
|
}
|
|
let meta = match entry.metadata().await {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
if meta.is_dir() {
|
|
entries.push(Entry { name, kind: EntryKind::Dir, size: None });
|
|
} else if meta.is_file() {
|
|
// Only expose audio files
|
|
if is_audio_file(&name) {
|
|
entries.push(Entry {
|
|
name,
|
|
kind: EntryKind::File,
|
|
size: Some(meta.len()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort: dirs first, then files; alphabetically within each group
|
|
entries.sort_by(|a, b| {
|
|
let a_dir = matches!(a.kind, EntryKind::Dir);
|
|
let b_dir = matches!(b.kind, EntryKind::Dir);
|
|
b_dir.cmp(&a_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
});
|
|
|
|
let response = BrowseResponse {
|
|
path: safe,
|
|
entries,
|
|
};
|
|
|
|
(StatusCode::OK, Json(response)).into_response()
|
|
}
|
|
|
|
/// Whitelist of audio extensions served via the web player.
|
|
pub fn is_audio_file(name: &str) -> bool {
|
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
matches!(
|
|
ext.as_str(),
|
|
"mp3" | "flac" | "ogg" | "opus" | "aac" | "m4a" | "wav" | "ape" | "wv" | "wma" | "tta" | "aiff" | "aif"
|
|
)
|
|
}
|
|
|
|
/// Returns true if the format needs transcoding (not natively supported by browsers).
|
|
pub fn needs_transcode(name: &str) -> bool {
|
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
matches!(ext.as_str(), "ape" | "wv" | "wma" | "tta" | "aiff" | "aif")
|
|
}
|