Skip to content

Commit 1b72c12

Browse files
authored
Merge pull request #12 from santoshxshrestha/images
fix: image support - custom builder for ammonia > allow align attribute
2 parents 4466190 + 6bcf860 commit 1b72c12

File tree

3 files changed

+172
-4
lines changed

3 files changed

+172
-4
lines changed

Cargo.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ documentation = "https://github.com/santoshxshrestha/mdwatch#readme"
1111
readme = "README.md"
1212

1313
[dependencies]
14+
actix-files = "0.6.10"
1415
actix-web = "4.11.0"
1516
actix-ws = "0.4.0"
1617
ammonia = "4.1.1"
@@ -19,6 +20,7 @@ clap = { version = "4.5.46", features = ["derive"] }
1920
notify = "8.2.0"
2021
notify-debouncer-full = "0.7.0"
2122
pulldown-cmark = "0.13.0"
23+
regex = "1.12.3"
2224
rust-embed = { version = "8.11.0", features = ["interpolate-folder-path"] }
2325
tokio = { version = "1.49.0", features = ["full"] }
2426
webbrowser = "1.0.5"

src/main.rs

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
use actix_web::web;
2+
use ammonia::UrlRelative::PassThrough;
23
use notify::event::RemoveKind;
34
use notify_debouncer_full::DebouncedEvent;
45
use notify_debouncer_full::{DebounceEventResult, new_debouncer, notify::*};
56
use pulldown_cmark::Options;
6-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
78
use std::time::{Duration, Instant};
89
use tokio::fs;
910
mod args;
11+
use actix_files::NamedFile;
1012
use actix_web::App;
1113
use actix_web::HttpServer;
1214
use actix_web::Responder;
1315
use actix_web::get;
1416
use actix_web::{HttpRequest, HttpResponse};
15-
use ammonia::clean;
17+
use ammonia::Builder;
1618
use args::MdwatchArgs;
1719
use askama::Template;
1820
use clap::Parser;
21+
use regex::Regex;
1922

2023
use notify::{RecursiveMode, event::ModifyKind};
2124
use rust_embed::Embed;
@@ -104,14 +107,46 @@ async fn ws_handler(
104107
Ok(response)
105108
}
106109

110+
/// Rewrite local image `src` attributes to use the `/_local_image/` prefix.
111+
/// Remote images (http://, https://, //, data:) are left untouched.
112+
fn rewrite_image_paths(html: &str) -> String {
113+
let re = Regex::new(r#"(<img\s[^>]*?src\s*=\s*")([^"]*?)(")"#).expect("invalid regex");
114+
re.replace_all(html, |caps: &regex::Captures| {
115+
let prefix = &caps[1];
116+
let src = &caps[2];
117+
let suffix = &caps[3];
118+
// Skip remote URLs and data URIs
119+
if src.starts_with("http://")
120+
|| src.starts_with("https://")
121+
|| src.starts_with("//")
122+
|| src.starts_with("data:")
123+
{
124+
format!("{}{}{}", prefix, src, suffix)
125+
} else {
126+
format!("{}/_local_image/{}{}", prefix, src, suffix)
127+
}
128+
})
129+
.to_string()
130+
}
131+
132+
/// Sanitize HTML while preserving relative URLs (needed for /_local_image/ paths).
133+
fn sanitize_html(html: &str) -> String {
134+
Builder::default()
135+
.url_relative(PassThrough)
136+
.add_generic_attributes(&["align"])
137+
.clean(html)
138+
.to_string()
139+
}
140+
107141
async fn get_markdown(file_path: &String) -> std::io::Result<String> {
108142
let markdown_input: String = fs::read_to_string(file_path).await?;
109143
let options = Options::all();
110144
let parser = pulldown_cmark::Parser::new_ext(&markdown_input, options);
111145

112146
let mut html_output = String::new();
113147
pulldown_cmark::html::push_html(&mut html_output, parser);
114-
html_output = clean(&html_output);
148+
html_output = rewrite_image_paths(&html_output);
149+
html_output = sanitize_html(&html_output);
115150
Ok(html_output)
116151
}
117152

@@ -196,14 +231,55 @@ async fn home(file: web::Data<String>) -> actix_web::Result<HttpResponse> {
196231
}
197232
}
198233

234+
/// Serve local image files referenced in the markdown.
235+
/// Resolves the requested path relative to the markdown file's parent directory.
236+
#[get("/_local_image/{path:.*}")]
237+
async fn serve_local_image(
238+
path: web::Path<String>,
239+
base_dir: web::Data<PathBuf>,
240+
) -> actix_web::Result<NamedFile> {
241+
let requested = path.into_inner();
242+
let resolved = base_dir.join(&requested);
243+
244+
// Canonicalize to prevent directory traversal attacks (e.g. ../../etc/passwd)
245+
let canonical = resolved
246+
.canonicalize()
247+
.map_err(|_| actix_web::error::ErrorNotFound("Image not found"))?;
248+
249+
let base_canonical = base_dir
250+
.canonicalize()
251+
.map_err(|_| actix_web::error::ErrorInternalServerError("Invalid base directory"))?;
252+
253+
if !canonical.starts_with(&base_canonical) {
254+
return Err(actix_web::error::ErrorForbidden(
255+
"Access denied: path outside base directory",
256+
));
257+
}
258+
259+
Ok(NamedFile::open(canonical)?)
260+
}
261+
199262
#[actix_web::main]
200263
async fn main() -> std::io::Result<()> {
201264
let args = MdwatchArgs::parse();
202265

203266
let MdwatchArgs { file, ip, port } = args;
204267

268+
// Resolve the parent directory of the markdown file for serving local images
269+
let file_path = Path::new(&file);
270+
let base_dir: PathBuf = file_path
271+
.parent()
272+
.map(|p| {
273+
if p.as_os_str().is_empty() {
274+
PathBuf::from(".")
275+
} else {
276+
p.to_path_buf()
277+
}
278+
})
279+
.unwrap_or_else(|| PathBuf::from("."));
280+
205281
if ip == "0.0.0.0" {
206-
eprintln!(" Warning: Binding to 0.0.0.0 exposes your server to the entire network!");
282+
eprintln!(" Warning: Binding to 0.0.0.0 exposes your server to the entire network!");
207283
eprintln!(" Make sure you trust your network or firewall settings.");
208284
}
209285

@@ -218,9 +294,52 @@ async fn main() -> std::io::Result<()> {
218294
App::new()
219295
.route("/ws", web::get().to(ws_handler))
220296
.service(home)
297+
.service(serve_local_image)
221298
.app_data(web::Data::new(file.clone()))
299+
.app_data(web::Data::new(base_dir.clone()))
222300
})
223301
.bind(format!("{}:{}", ip, port))?
224302
.run()
225303
.await
226304
}
305+
306+
#[cfg(test)]
307+
mod tests {
308+
use super::*;
309+
310+
struct TestCase {
311+
input: &'static str,
312+
expected: &'static str,
313+
}
314+
315+
#[test]
316+
fn test_rewrite_image_paths() {
317+
let test_cases = [
318+
TestCase {
319+
input: r#"<img src="image.png" alt="Image">"#,
320+
expected: r#"<img src="/_local_image/image.png" alt="Image">"#,
321+
},
322+
TestCase {
323+
input: r#"<img src="http://example.com/image.png" alt="Remote Image">"#,
324+
expected: r#"<img src="http://example.com/image.png" alt="Remote Image">"#,
325+
},
326+
TestCase {
327+
input: r#"<img src="data:image/png;base64,..." alt="Data URI">"#,
328+
expected: r#"<img src="data:image/png;base64,..." alt="Data URI">"#,
329+
},
330+
TestCase {
331+
input: r#"<img src="//example.com/image.png" alt="Protocol-relative URL">"#,
332+
expected: r#"<img src="//example.com/image.png" alt="Protocol-relative URL">"#,
333+
},
334+
];
335+
336+
for case in test_cases {
337+
let result = rewrite_image_paths(case.input);
338+
assert_eq!(
339+
result, case.expected,
340+
"Failed to rewrite image paths for input: {}",
341+
case.input
342+
);
343+
}
344+
}
345+
}

0 commit comments

Comments
 (0)