431 lines
15 KiB
Rust
431 lines
15 KiB
Rust
use axum::{
|
||
extract::{Path, State},
|
||
routing::get,
|
||
Router,
|
||
http::{StatusCode, HeaderMap, header},
|
||
response::{Html, Sse, IntoResponse},
|
||
};
|
||
use pulldown_cmark::{Parser, Options, html, Event, Tag, CodeBlockKind};
|
||
use std::net::SocketAddr;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use tokio::fs;
|
||
use tokio::sync::broadcast;
|
||
use tokio_stream::wrappers::BroadcastStream;
|
||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, EventKind};
|
||
use futures::StreamExt;
|
||
use std::convert::Infallible;
|
||
use std::time::Duration;
|
||
|
||
// Импорт для syntect
|
||
use syntect::easy::HighlightLines;
|
||
use syntect::highlighting::ThemeSet;
|
||
use syntect::html::{styled_line_to_highlighted_html, IncludeBackground};
|
||
use syntect::parsing::SyntaxSet;
|
||
|
||
/// Храним тяжелые ресурсы и канал для уведомлений
|
||
#[derive(Clone)]
|
||
struct AppState {
|
||
syntax_set: Arc<SyntaxSet>,
|
||
theme_set: Arc<ThemeSet>,
|
||
tx: Arc<broadcast::Sender<String>>,
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
tracing_subscriber::fmt::init();
|
||
|
||
println!("Загрузка баз синтаксисов и тем...");
|
||
let ss = SyntaxSet::load_defaults_newlines();
|
||
let ts = ThemeSet::load_defaults();
|
||
|
||
// Создаем канал broadcast для SSE
|
||
let (tx, _rx) = broadcast::channel::<String>(100);
|
||
|
||
let state = AppState {
|
||
syntax_set: Arc::new(ss),
|
||
theme_set: Arc::new(ts),
|
||
tx: Arc::new(tx),
|
||
};
|
||
|
||
// Запускаем глобальный вотчер в отдельной задаче
|
||
let watcher_state = state.clone();
|
||
tokio::spawn(async move {
|
||
run_file_watcher(watcher_state).await;
|
||
});
|
||
|
||
let app = Router::new()
|
||
.route("/", get(root))
|
||
.route("/*path", get(serve_file))
|
||
.route("/events/*path", get(sse_handler))
|
||
.with_state(state);
|
||
|
||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||
println!("Сервер запущен на http://{}", addr);
|
||
println!("Пример: http://127.0.0.1:3000/files/readme.md");
|
||
|
||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||
axum::serve(listener, app).await.unwrap();
|
||
}
|
||
|
||
async fn root() -> Html<&'static str> {
|
||
Html("<h1 style='color:white; text-align:center;'>Markdown Server</h1><p style='color:#aaa; text-align:center;'>Перейдите на <a href='/files/example.md'>/files/example.md</a></p>")
|
||
}
|
||
|
||
async fn sse_handler(
|
||
State(state): State<AppState>,
|
||
Path(full_path): Path<String>,
|
||
) -> impl IntoResponse {
|
||
let mut headers = HeaderMap::new();
|
||
headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("text/event-stream"));
|
||
headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-cache"));
|
||
headers.insert(header::CONNECTION, header::HeaderValue::from_static("keep-alive"));
|
||
|
||
let rx = state.tx.subscribe();
|
||
let requested_path = full_path.clone();
|
||
|
||
let stream = BroadcastStream::new(rx)
|
||
.filter_map(move |res| {
|
||
let req_path = requested_path.clone();
|
||
async move {
|
||
match res {
|
||
Ok(changed_path) => {
|
||
if changed_path.contains(&req_path) {
|
||
Some(Ok::<axum::response::sse::Event, Infallible>(
|
||
axum::response::sse::Event::default()
|
||
.event("reload")
|
||
.data("")
|
||
))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
});
|
||
|
||
let sse = Sse::new(stream)
|
||
.keep_alive(
|
||
axum::response::sse::KeepAlive::new()
|
||
.interval(Duration::from_secs(15))
|
||
.text("ping")
|
||
);
|
||
|
||
(headers, sse)
|
||
}
|
||
|
||
async fn serve_file(
|
||
State(state): State<AppState>,
|
||
Path(full_path): Path<String>,
|
||
) -> Result<Html<String>, StatusCode> {
|
||
if full_path.is_empty() {
|
||
return Err(StatusCode::NOT_FOUND);
|
||
}
|
||
|
||
let mut requested_path = PathBuf::from("./notes");
|
||
requested_path.push(&full_path);
|
||
|
||
let safe_path = match fs::canonicalize(&requested_path).await {
|
||
Ok(p) => p,
|
||
Err(_) => return Err(StatusCode::NOT_FOUND),
|
||
};
|
||
|
||
let base_dir = match fs::canonicalize("./notes").await {
|
||
Ok(p) => p,
|
||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||
};
|
||
|
||
if !safe_path.starts_with(&base_dir) {
|
||
eprintln!("Попытка выхода за пределы директории: {:?}", safe_path);
|
||
return Err(StatusCode::FORBIDDEN);
|
||
}
|
||
|
||
let content = match fs::read_to_string(&safe_path).await {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
eprintln!("Ошибка чтения: {}", e);
|
||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||
}
|
||
};
|
||
|
||
let html_content = markdown_to_html(&content, &state.syntax_set, &state.theme_set, &full_path);
|
||
|
||
Ok(Html(html_content))
|
||
}
|
||
|
||
/// Запуск наблюдателя за файловой системой
|
||
async fn run_file_watcher(state: AppState) {
|
||
let (tx_fs, mut rx_fs) = tokio::sync::mpsc::channel::<PathBuf>(100);
|
||
|
||
let tx_fs_clone = tx_fs.clone();
|
||
let mut watcher = RecommendedWatcher::new(
|
||
move |res: Result<notify::Event, notify::Error>| {
|
||
if let Ok(event) = res {
|
||
if matches!(event.kind, EventKind::Modify(_)) {
|
||
for path in event.paths {
|
||
let _ = tx_fs_clone.blocking_send(path);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
Config::default(),
|
||
).expect("Failed to create watcher");
|
||
|
||
let watch_path = PathBuf::from("./notes");
|
||
if let Err(e) = watcher.watch(&watch_path, RecursiveMode::Recursive) {
|
||
eprintln!("Ошибка настройки watcher: {}", e);
|
||
return;
|
||
}
|
||
|
||
println!("Watcher запущен для директории: {:?}", watch_path);
|
||
|
||
while let Some(path) = rx_fs.recv().await {
|
||
if let Some(path_str) = path.to_str() {
|
||
let _ = state.tx.send(path_str.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
fn markdown_to_html(markdown: &str, ss: &SyntaxSet, ts: &ThemeSet, file_path: &str) -> String {
|
||
let theme = &ts.themes["base16-ocean.dark"];
|
||
|
||
let mut options = Options::empty();
|
||
options.insert(Options::ENABLE_TABLES);
|
||
options.insert(Options::ENABLE_FOOTNOTES);
|
||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||
options.insert(Options::ENABLE_TASKLISTS);
|
||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||
|
||
let parser = Parser::new_ext(markdown, options);
|
||
|
||
let mut processed_events: Vec<Event> = Vec::new();
|
||
let mut in_code_block = false;
|
||
let mut current_lang: Option<String> = None;
|
||
let mut current_code = String::new();
|
||
|
||
for event in parser {
|
||
match event {
|
||
Event::Start(Tag::CodeBlock(kind)) => {
|
||
in_code_block = true;
|
||
current_code.clear();
|
||
current_lang = if let CodeBlockKind::Fenced(l) = kind {
|
||
Some(l.to_string())
|
||
} else {
|
||
None
|
||
};
|
||
},
|
||
Event::End(Tag::CodeBlock(_)) => {
|
||
in_code_block = false;
|
||
|
||
// Определяем отображаемое имя языка
|
||
let lang_display = current_lang.as_deref().unwrap_or("text");
|
||
|
||
// Экранируем имя языка для HTML атрибута и текста
|
||
let lang_escaped = escape_html(lang_display);
|
||
|
||
// Подсветка синтаксиса (построчно)
|
||
let highlighted_html = if let Some(lang) = ¤t_lang {
|
||
if let Some(syntax) = ss.find_syntax_by_token(lang) {
|
||
let mut h = HighlightLines::new(syntax, theme);
|
||
let mut result_html = String::new();
|
||
|
||
for line in current_code.lines() {
|
||
let line_with_newline = format!("{}\n", line);
|
||
match h.highlight_line(&line_with_newline, ss) {
|
||
Ok(regions) => {
|
||
let html_line = styled_line_to_highlighted_html(®ions[..], IncludeBackground::No)
|
||
.unwrap_or_else(|_| escape_html(&line_with_newline));
|
||
result_html.push_str(&html_line);
|
||
},
|
||
Err(_) => result_html.push_str(&escape_html(&line_with_newline)),
|
||
}
|
||
}
|
||
result_html
|
||
} else {
|
||
escape_html(¤t_code)
|
||
}
|
||
} else {
|
||
escape_html(¤t_code)
|
||
};
|
||
|
||
// Формируем HTML с заголовком и кнопкой копирования
|
||
// Мы экранируем current_code еще раз для data-атрибута, хотя для копирования будем брать текст из pre
|
||
let code_container = format!(
|
||
r#"<div class="code-block-wrapper">
|
||
<div class="code-header">
|
||
<span class="code-lang">{}</span>
|
||
<button class="copy-btn" onclick="copyCode(this)">Copy</button>
|
||
</div>
|
||
<pre style="margin: 0; border-radius: 0 0 6px 6px;"><code>{}</code></pre>
|
||
</div>"#,
|
||
lang_escaped,
|
||
highlighted_html
|
||
);
|
||
|
||
processed_events.push(Event::Html(code_container.into()));
|
||
},
|
||
Event::Text(text) if in_code_block => {
|
||
current_code.push_str(&text);
|
||
},
|
||
_ => {
|
||
if !in_code_block {
|
||
processed_events.push(event);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut body_html = String::new();
|
||
html::push_html(&mut body_html, processed_events.into_iter());
|
||
|
||
let sse_url = format!("/events/{}", file_path);
|
||
|
||
format!(
|
||
r#"<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Markdown Preview</title>
|
||
<style>
|
||
body {{ background-color: #121212; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 40px 20px; display: flex; justify-content: center; line-height: 1.6; }}
|
||
.content {{ max-width: 800px; width: 100%; background-color: #1e1e1e; padding: 40px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }}
|
||
a {{ color: #bb86fc; text-decoration: none; }}
|
||
a:hover {{ text-decoration: underline; }}
|
||
h1, h2, h3, h4 {{ color: #ffffff; margin-top: 1.5em; margin-bottom: 0.5em; }}
|
||
h1 {{ border-bottom: 1px solid #333; padding-bottom: 10px; }}
|
||
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
|
||
th, td {{ border: 1px solid #444; padding: 8px; text-align: left; }}
|
||
th {{ background-color: #2c2c2c; }}
|
||
blockquote {{ border-left: 4px solid #bb86fc; margin: 1em 0; padding-left: 1em; color: #aaa; background: #252525; padding: 10px; }}
|
||
code {{ font-family: 'Consolas', 'Monaco', monospace; }}
|
||
|
||
/* Стили для обычных инлайн кодов */
|
||
p > code, li > code {{ background-color: #2c2c2c; padding: 2px 6px; border-radius: 4px; color: #ff79c6; }}
|
||
|
||
/* Стили для блоков кода с подсветкой */
|
||
.code-block-wrapper {{
|
||
margin: 1em 0;
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
background-color: #2b303b;
|
||
}}
|
||
.code-header {{
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background-color: #232730;
|
||
padding: 6px 12px;
|
||
border-bottom: 1px solid #444;
|
||
font-size: 0.85em;
|
||
color: #a0a0a0;
|
||
}}
|
||
.code-lang {{
|
||
font-weight: bold;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}}
|
||
.copy-btn {{
|
||
background: transparent;
|
||
border: 1px solid #555;
|
||
color: #ccc;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.8em;
|
||
transition: all 0.2s;
|
||
}}
|
||
.copy-btn:hover {{
|
||
background-color: #444;
|
||
color: #fff;
|
||
border-color: #777;
|
||
}}
|
||
.copy-btn:active {{
|
||
transform: scale(0.95);
|
||
}}
|
||
pre {{
|
||
padding: 15px;
|
||
overflow-x: auto;
|
||
margin: 0;
|
||
}}
|
||
pre code {{
|
||
background: transparent;
|
||
padding: 0;
|
||
color: inherit;
|
||
}}
|
||
|
||
#status {{ position: fixed; top: 10px; right: 10px; padding: 5px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; }}
|
||
.connected {{ background-color: #2ecc71; color: #000; }}
|
||
.disconnected {{ background-color: #e74c3c; color: #fff; }}
|
||
.reconnecting {{ background-color: #f1c40f; color: #000; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="content">
|
||
{}
|
||
</div>
|
||
|
||
<script>
|
||
const sseUrl = "{}";
|
||
|
||
function connect() {{
|
||
const evtSource = new EventSource(sseUrl);
|
||
|
||
evtSource.onerror = (err) => {{
|
||
evtSource.close();
|
||
setTimeout(connect, 3000);
|
||
}};
|
||
|
||
evtSource.addEventListener("reload", (event) => {{
|
||
console.log("Получено событие обновления");
|
||
location.reload();
|
||
}});
|
||
}}
|
||
|
||
connect();
|
||
|
||
function copyCode(button) {{
|
||
// Находим обертку, затем pre внутри неё
|
||
const wrapper = button.closest('.code-block-wrapper');
|
||
if (!wrapper) return;
|
||
|
||
const pre = wrapper.querySelector('pre');
|
||
if (!pre) return;
|
||
|
||
// Получаем текстовое содержимое (без HTML тегов подсветки)
|
||
const codeText = pre.innerText;
|
||
|
||
navigator.clipboard.writeText(codeText).then(() => {{
|
||
const originalText = button.innerText;
|
||
button.innerText = 'Copied!';
|
||
button.style.borderColor = '#2ecc71';
|
||
button.style.color = '#2ecc71';
|
||
|
||
setTimeout(() => {{
|
||
button.innerText = originalText;
|
||
button.style.borderColor = '';
|
||
button.style.color = '';
|
||
}}, 2000);
|
||
}}).catch(err => {{
|
||
console.error('Ошибка копирования:', err);
|
||
button.innerText = 'Error';
|
||
}});
|
||
}}
|
||
</script>
|
||
</body>
|
||
</html>"#,
|
||
body_html,
|
||
sse_url
|
||
)
|
||
}
|
||
|
||
fn escape_html(text: &str) -> String {
|
||
text.replace('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
.replace('\'', "'")
|
||
}
|