Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 91 additions & 10 deletions src/confluence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use tracing::{debug, warn};
use url::Url;

use crate::utils::{
HeaderHints, decode_html_attribute, ensure_dir, get_file_name_from_url_or_headers, resolve_url,
to_markdown_asset_path,
HeaderHints, URI_COMPONENT, decode_html_attribute, ensure_dir,
get_file_name_from_url_or_headers, resolve_url, to_markdown_asset_path,
};

// ── Public types ───────────────────────────────────────────────────
Expand Down Expand Up @@ -444,18 +444,19 @@ pub async fn download_images_and_rewrite_html(
}

static REPLACE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?is)(<img\b[^>]*\bsrc=)(["'])(.*?)(["'])"#).unwrap());
Lazy::new(|| Regex::new(r#"(?is)(<img\b[^>]*\bsrc=)(?:"([^"]*)"|'([^']*)')"#).unwrap());

let result = REPLACE_RE.replace_all(html, |caps: &regex::Captures<'_>| {
let prefix = &caps[1];
let quote_open = &caps[2];
let src = &caps[3];
let quote_close = &caps[4];
if quote_open != quote_close {
let (quote, src) = if let Some(m) = caps.get(2) {
('"', m.as_str())
} else if let Some(m) = caps.get(3) {
('\'', m.as_str())
} else {
return caps[0].to_owned();
}
};
match src_to_local.get(src) {
Some(local) => format!("{prefix}{quote_open}{local}{quote_close}"),
Some(local) => format!("{prefix}{quote}{local}{quote}"),
None => caps[0].to_owned(),
}
});
Expand All @@ -464,7 +465,7 @@ pub async fn download_images_and_rewrite_html(
}

fn is_local_markdown_asset(src: &str, markdown_image_prefix: &str) -> bool {
let encoded_prefix = utf8_percent_encode(markdown_image_prefix, PATH_SEGMENT).to_string();
let encoded_prefix = utf8_percent_encode(markdown_image_prefix, &URI_COMPONENT).to_string();
src.starts_with(&format!("{markdown_image_prefix}/"))
|| src.starts_with(&format!("{encoded_prefix}%2F"))
}
Expand Down Expand Up @@ -708,6 +709,53 @@ mod tests {
assert_eq!(id, "777888");
}

#[tokio::test]
async fn rewrite_html_rewrites_image_src_when_url_contains_apostrophe() {
let server = MockServer::start().await;
// Confluence embeds the raw page title (with apostrophe) in the URL.
let img_path = "/download/attachments/123/My%20team's%20page/image.png";
Mock::given(method("GET"))
.and(path(img_path))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\n"))
.mount(&server)
.await;

let html = format!(r#"<img src="{}{}" />"#, server.uri(), img_path);

let tmp_dir = std::env::temp_dir().join(format!(
"confluence2md_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
let assets_dir = tmp_dir.join("assets");
let client = Client::new();
let mut used = HashSet::new();
let result = download_images_and_rewrite_html(
&client,
&html,
DownloadImagesOptions {
base_url: &server.uri(),
personal_access_token: "token",
assets_abs_dir: &assets_dir,
markdown_image_prefix: "assets",
used_names: &mut used,
},
)
.await
.unwrap();

assert!(
!result.contains(&server.uri()),
"src should be rewritten to local path, got: {result}"
);
assert!(
result.contains("assets"),
"src should point into assets dir, got: {result}"
);
}

#[tokio::test]
async fn fetch_confluence_page_preserves_content_json_response() {
let server = MockServer::start().await;
Expand All @@ -729,4 +777,37 @@ mod tests {
assert_eq!(page.export_html, "<p>export</p>");
assert_eq!(page.storage_html.as_deref(), Some("<p>storage</p>"));
}

// Regression test for: is_local_markdown_asset fails when the markdown_image_prefix
// contains an apostrophe because the old code used PATH_SEGMENT encoding (which
// encodes `'` → `%27`) while to_markdown_asset_path uses URI_COMPONENT encoding
// (which keeps `'` as a literal). The mismatch caused local draw.io / image assets
// to be treated as remote URLs and re-downloaded.
#[test]
fn is_local_markdown_asset_recognizes_apostrophe_in_prefix() {
let prefix = "confluence2md's_test_assets";
let src = to_markdown_asset_path(prefix, "single.drawio.png");
// With the old PATH_SEGMENT encoding, encoded_prefix would contain %27 instead
// of the literal apostrophe used by to_markdown_asset_path, so starts_with
// would return false and this assertion would fail.
assert!(
is_local_markdown_asset(&src, prefix),
"local asset not recognized (apostrophe in prefix): {src}"
);
}

#[test]
fn is_local_markdown_asset_recognizes_plain_prefix() {
let prefix = "my_page_assets";
let src = to_markdown_asset_path(prefix, "image.png");
assert!(is_local_markdown_asset(&src, prefix));
}

#[test]
fn is_local_markdown_asset_rejects_remote_url() {
assert!(!is_local_markdown_asset(
"https://example.com/image.png",
"my_page_assets"
));
}
}
2 changes: 1 addition & 1 deletion src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ pub fn make_assets_info(page_id: &str, title: &str, output_path: &Path) -> Asset

// `encodeURIComponent` percent-encodes everything that is not in the unreserved
// set `A-Z a-z 0-9 - _ . ! ~ * ' ( )`. Construct the inverse `AsciiSet`.
const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC
pub const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
Expand Down