Files
furumi-ng/furumi-server/src/web/browse.rs
2026-03-17 13:49:03 +00:00

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")
}