11use actix_web:: web;
2+ use ammonia:: UrlRelative :: PassThrough ;
23use notify:: event:: RemoveKind ;
34use notify_debouncer_full:: DebouncedEvent ;
45use notify_debouncer_full:: { DebounceEventResult , new_debouncer, notify:: * } ;
56use pulldown_cmark:: Options ;
6- use std:: path:: Path ;
7+ use std:: path:: { Path , PathBuf } ;
78use std:: time:: { Duration , Instant } ;
89use tokio:: fs;
910mod args;
11+ use actix_files:: NamedFile ;
1012use actix_web:: App ;
1113use actix_web:: HttpServer ;
1214use actix_web:: Responder ;
1315use actix_web:: get;
1416use actix_web:: { HttpRequest , HttpResponse } ;
15- use ammonia:: clean ;
17+ use ammonia:: Builder ;
1618use args:: MdwatchArgs ;
1719use askama:: Template ;
1820use clap:: Parser ;
21+ use regex:: Regex ;
1922
2023use notify:: { RecursiveMode , event:: ModifyKind } ;
2124use 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+
107141async 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]
200263async 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