feat: add random file button + image render

This commit is contained in:
thek4n 2026-03-20 16:20:54 +03:00
parent de74585eee
commit 65a59ce59c
5 changed files with 182 additions and 4 deletions

82
Cargo.lock generated
View File

@ -460,6 +460,17 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -681,8 +692,10 @@ dependencies = [
"axum",
"clap",
"futures",
"mime_guess",
"notify",
"pulldown-cmark",
"rand",
"serde",
"syntect",
"tokio",
@ -703,6 +716,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -870,6 +893,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -909,6 +941,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -1564,6 +1626,26 @@ dependencies = [
"linked-hash-map",
]
[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@ -24,3 +24,5 @@ tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3"
clap = { version = "4.5", features = ["derive"] }
tower-http = { version = "0.6.8", features = ["trace", "compression-gzip", "cors"] }
mime_guess = "2"
rand = "0.8"

View File

@ -6,8 +6,10 @@ use axum::{
routing::get,
};
use futures::StreamExt;
use mime_guess::from_path;
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, html};
use rand::seq::SliceRandom;
use std::convert::Infallible;
use std::fmt::Write;
use std::net::{SocketAddr, ToSocketAddrs};
@ -91,6 +93,7 @@ async fn main() {
let app = Router::new()
.route("/", get(root))
.route("/random", get(random_file))
.route("/*path", get(serve_file))
.route("/events/*path", get(sse_handler))
.with_state(state)
@ -170,7 +173,7 @@ async fn sse_handler(
async fn serve_file(
State(state): State<AppState>,
Path(full_path): Path<String>,
) -> Result<Html<String>, StatusCode> {
) -> Result<impl IntoResponse, StatusCode> {
if full_path.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
@ -199,14 +202,44 @@ async fn serve_file(
Ok(m) => m,
Err(e) => {
eprintln!("Ошибка получения метаданных: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
return Err(StatusCode::NOT_FOUND); // Лучше NOT_FOUND для отсутствующих файлов
}
};
// 1. Если это директория - рендерим индекс
if metadata.is_dir() {
return render_directory_index(&safe_path, &full_path).await;
return render_directory_index(&safe_path, &full_path)
.await
.map(|h| h.into_response());
}
// 2. ПРОВЕРКА НА ИЗОБРАЖЕНИЯ (И другую статику)
// Распространенные расширения изображений
let extension = safe_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
let is_image = matches!(
extension.to_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico"
);
if is_image {
// Читаем файл в байты
let file_content = match fs::read(&safe_path).await {
Ok(content) => content,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Определяем MIME тип
let mime_type = from_path(&safe_path).first_or_octet_stream();
// Возвращаем файл с правильным заголовком Content-Type
return Ok(([(header::CONTENT_TYPE, mime_type.as_ref())], file_content).into_response());
}
// 3. Если это не картинка и не папка, считаем что это Markdown (или текст)
// Читаем контент как строку
let content = match fs::read_to_string(&safe_path).await {
Ok(c) => c,
Err(e) => {
@ -244,7 +277,7 @@ async fn serve_file(
.replace("{{SSE_URL}}", &format!("/events/{full_path}"))
.replace("{{BACK_BUTTON}}", &back_button_html);
Ok(Html(final_html))
Ok(Html(final_html).into_response())
}
async fn render_directory_index(
@ -472,6 +505,57 @@ fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, _file_path: &
body_html
}
async fn random_file(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> {
let mut files: Vec<PathBuf> = Vec::new();
// Рекурсивно читаем директорию
let mut stack = vec![state.root.clone()];
while let Some(current_dir) = stack.pop() {
let mut entries = match fs::read_dir(&current_dir).await {
Ok(list) => list,
Err(_) => continue, // Пропускаем недоступные папки
};
while let Some(entry) = entries
.next_entry()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let path = entry.path();
// Пропускаем скрытые файлы/папки
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
if entry.metadata().await.map(|m| m.is_dir()).unwrap_or(false) {
stack.push(path);
} else {
files.push(path);
}
}
}
if files.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
// Выбираем случайный файл
let mut rng = rand::thread_rng();
let random_path = files.choose(&mut rng).unwrap();
// Получаем относительный путь от корня для формирования URL
let relative_path = random_path
.strip_prefix(&state.root)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let url_path = relative_path.to_string_lossy().replace('\\', "/"); // Нормализация для Windows
// Перенаправление (302 Found)
Ok(axum::response::Redirect::temporary(&format!("/{url_path}")))
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")

View File

@ -26,6 +26,7 @@
{{FILE_LIST}}
<div class="footer">
<a href="/">На главную</a>
<a href="/random">🎲 Случайный файл</a>
</div>
</div>
</body>

View File

@ -33,6 +33,15 @@
.connected { background-color: #2ecc71; color: #000; }
.disconnected { background-color: #e74c3c; color: #fff; }
.reconnecting { background-color: #f1c40f; color: #000; }
img {
max-width: 100%;
height: auto;
display: block;
margin: 1.5em auto;
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
</style>
</head>
<body>