diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7691369..1e7c2a7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -50,7 +50,7 @@ The CLI itself is internally pipelined: each stage consumes the HTML produced by
```mermaid
flowchart TD
- A[main.rs
parse CLI / env] --> B[confluence::resolve_page_id_from_url]
+ A[main.rs
parse CLI / env] --> B[confluence::resolve_page_id_from_url
short-link/tinyurl.action redirects resolved internally]
B --> C[confluence::fetch_confluence_page
export_view / storage HTML]
C --> D[confluence::list_attachments
+ build_attachment_maps]
D --> E[drawio::resolve_drawio_diagrams
draw.io macros / rendered imgs → .drawio.png]
@@ -87,7 +87,7 @@ confluence2md is a single Rust crate that exposes one binary (`confluence2md`) a
- **Key functions:**
- `build_http_client` — `reqwest` client backed by `rustls`. Trust anchors come from both the bundled Mozilla WebPKI root set (`rustls-tls-webpki-roots`) and the host OS certificate store (`rustls-tls-native-roots`); the two sets are merged so corporate / internal CAs trusted by the host work without configuration, and minimal container images without a system CA bundle still work via the bundled roots.
- `get_required_env` — reads the personal access token from `CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN`.
- - `resolve_page_id_from_url` — supports `pageId`, `/spaces/.../pages//`, `/display//`, and `spaceKey`+`title` URL formats.
+ - `resolve_page_id_from_url` — first resolves any `/x/` short link and the `tinyurl.action?urlIdentifier=...` hop it redirects through (no auto-redirect; each `Location` must be HTTPS, userinfo-free, and same-origin before it's trusted, bounded by `MAX_REDIRECT_HOPS`), then supports `pageId`, `/spaces/.../pages//`, `/display//`, and `spaceKey`+`title` URL formats on the resolved URL.
- `fetch_confluence_page` — fetches the page with `body.export_view` and `body.storage` expansions.
- `list_attachments`, `build_attachment_maps` — paginated attachment listing keyed by title and by media ID.
- `download_images_and_rewrite_html` — replaces regular `
` values with local relative paths under the assets directory, skipping PlantUML endpoints and assets already rewritten to local paths.
@@ -157,6 +157,13 @@ sequenceDiagram
User->>CLI: confluence2md
CLI->>CLI: parse args + env, init logger
CLI->>Conf: resolve_page_id_from_url(url)
+ opt url is a /x/ or tinyurl.action redirect
+ loop up to MAX_REDIRECT_HOPS
+ Conf->>API: GET url (redirects disabled)
+ API-->>Conf: 3xx + Location
+ Conf->>Conf: validate HTTPS, no userinfo, same origin
+ end
+ end
Conf->>API: GET /rest/api/content?...
API-->>Conf: page id
CLI->>Conf: fetch_confluence_page(id)
@@ -292,6 +299,7 @@ There is no database, cache, or message queue.
## 6. External Integrations / APIs
- **Confluence REST API v1** — read-only access to pages and attachments: `GET /rest/api/content/{id}?expand=body.storage,body.export_view`, `GET /rest/api/content/{id}/child/attachment` (paginated), `GET /rest/api/content/search?cql=...` (for title-based URL resolution), and binary downloads under `/download/attachments/{pageId}/...`.
+- **Confluence shortened links** — `GET /x/` and the `tinyurl.action` hop it redirects through, fetched with redirect-following disabled to read `Location` at each hop (see §3.2).
- **Authentication:** HTTP `Authorization: Bearer ` using a Personal Access Token.
No other external services are called.
diff --git a/README.md b/README.md
index e8d1403..f53e831 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,9 @@ confluence2md automatically detects the page from various Confluence URL formats
| Cloud `/spaces/.../pages/` path | `https://confluence.example.com/wiki/spaces/DEMO/pages/9876543/My+Page` |
| Classic `/display/` path | `https://confluence.example.com/display/DEMO/My+Page+Title` |
| `spaceKey` + `title` params | `https://confluence.example.com/pages/viewpage.action?spaceKey=DEMO&title=My+Page` |
+| Shortened `/x/` link | `https://confluence.example.com/x/AbCdEf` |
+
+Shortened links redirect through an intermediate `tinyurl.action?urlIdentifier=...` URL before reaching the real page; both hops are resolved without auto-following redirects, validating each `Location` (HTTPS, no userinfo, same origin) before use.
## 🛠️ Tech Stack
diff --git a/src/confluence.rs b/src/confluence.rs
index 9c9c420..2784f06 100644
--- a/src/confluence.rs
+++ b/src/confluence.rs
@@ -4,7 +4,9 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
+use once_cell::sync::Lazy;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
+use regex::Regex;
use reqwest::Client;
use reqwest::header::{
ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderValue,
@@ -19,6 +21,9 @@ use crate::utils::{
get_file_name_from_url_or_headers, resolve_url, to_markdown_asset_path,
};
+const USER_AGENT: &str = concat!("confluence2md/", env!("CARGO_PKG_VERSION"));
+const SHORT_URL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
+
// ── Public types ───────────────────────────────────────────────────
#[derive(Debug, Clone)]
@@ -475,8 +480,6 @@ pub async fn download_images_and_rewrite_html(
html: &str,
opts: DownloadImagesOptions<'_>,
) -> Result {
- use once_cell::sync::Lazy;
- use regex::Regex;
static IMG_RE: Lazy =
Lazy::new(|| Regex::new(r#"(?is)
]*\bsrc=(?:"([^"]*)"|'([^']*)')[^>]*>"#).unwrap());
static PLANTUML_RE: Lazy = Lazy::new(|| Regex::new(r"(?i)/rest/plantuml/").unwrap());
@@ -601,7 +604,10 @@ pub async fn resolve_page_id_from_url(
base_url: &str,
token: &str,
) -> Result {
- let parsed = Url::parse(page_url).context("invalid URL")?;
+ // Expand `/x/` short links and the `tinyurl.action?urlIdentifier=...`
+ // hop they redirect through before applying any of the patterns below.
+ let resolved_url = resolve_confluence_redirects(page_url, token).await?;
+ let parsed = Url::parse(&resolved_url).context("invalid URL")?;
// 1. pageId query param.
for (k, v) in parsed.query_pairs() {
@@ -611,8 +617,6 @@ pub async fn resolve_page_id_from_url(
}
// 2. /spaces/SPACE/pages/{pageId}.
- use once_cell::sync::Lazy;
- use regex::Regex;
static WIKI_RE: Lazy = Lazy::new(|| Regex::new(r"/spaces/[^/]+/pages/(\d+)").unwrap());
if let Some(c) = WIKI_RE.captures(parsed.path()) {
return Ok(c[1].to_owned());
@@ -640,7 +644,7 @@ pub async fn resolve_page_id_from_url(
return lookup_page_id_by_space_and_title(client, &space, &title, base_url, token).await;
}
- bail!("Cannot determine page ID from URL: {page_url}")
+ bail!("Cannot determine page ID from URL: {resolved_url}")
}
fn decode_path_segment(s: &str) -> String {
@@ -649,10 +653,123 @@ fn decode_path_segment(s: &str) -> String {
.into_owned()
}
+// ── Short URL expansion ────────────────────────────────────────────
+
+// Matches `/x/` as the final path segment, with any context path in
+// front of it (e.g. Confluence Cloud's `/wiki/x/`).
+static SHORT_URL_RE: Lazy = Lazy::new(|| Regex::new(r"(?:^|/)x/[A-Za-z0-9_-]+/?$").unwrap());
+
+// Matches the `tinyurl.action` endpoint that `/x/` redirects through
+// before reaching the real page URL (e.g. `/pages/tinyurl.action`).
+static TINYURL_ACTION_RE: Lazy =
+ Lazy::new(|| Regex::new(r"(?:^|/)tinyurl\.action$").unwrap());
+
+const MAX_REDIRECT_HOPS: usize = 5;
+
+/// Returns `true` if `url` is a Confluence endpoint that only redirects
+/// (`/x/` or `tinyurl.action?urlIdentifier=...`) rather than a page URL.
+fn is_confluence_redirect_url(parsed: &Url) -> bool {
+ SHORT_URL_RE.is_match(parsed.path())
+ || (TINYURL_ACTION_RE.is_match(parsed.path())
+ && parsed.query_pairs().any(|(k, _)| k == "urlIdentifier"))
+}
+
+/// Resolves `/x/` short links and the `tinyurl.action` hop they
+/// redirect through, returning the first URL that isn't itself a redirect.
+///
+/// `url` must be HTTPS, checked before any request is sent so the token is
+/// never sent in cleartext. Non-redirect URLs (the common case) pass through
+/// untouched, with no request made at all.
+async fn resolve_confluence_redirects(url: &str, token: &str) -> Result {
+ let parsed = Url::parse(url).context("invalid URL")?;
+ if !is_confluence_redirect_url(&parsed) {
+ return Ok(url.to_owned());
+ }
+ if parsed.scheme() != "https" {
+ bail!("Confluence short URL must use HTTPS: {url}");
+ }
+ follow_redirect_chain(parsed, token).await
+}
+
+/// Iterates the redirect chain. Split out from [`resolve_confluence_redirects`]
+/// so tests can exercise it directly against a plain-HTTP wiremock server;
+/// each hop's `Location` is still validated (HTTPS, no userinfo, same
+/// origin) before it's trusted, which transitively guarantees every hop
+/// after the first is HTTPS.
+async fn follow_redirect_chain(url: Url, token: &str) -> Result {
+ let client = Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(SHORT_URL_TIMEOUT)
+ .user_agent(USER_AGENT)
+ .build()
+ .context("build HTTP client for short URL expansion")?;
+
+ let mut current = url;
+ for _ in 0..MAX_REDIRECT_HOPS {
+ if !is_confluence_redirect_url(¤t) {
+ return Ok(current.to_string());
+ }
+ current = fetch_and_validate_redirect(&client, ¤t, token).await?;
+ }
+ if !is_confluence_redirect_url(¤t) {
+ return Ok(current.to_string());
+ }
+ bail!("Too many redirects while resolving Confluence short URL: {current}")
+}
+
+/// Sends one request to a redirect-issuing URL and validates its `Location`.
+async fn fetch_and_validate_redirect(client: &Client, url: &Url, token: &str) -> Result {
+ let response = client
+ .get(url.clone())
+ .headers(auth_headers(token))
+ .send()
+ .await
+ .with_context(|| format!("HTTP request failed: {url}"))?;
+
+ let status = response.status();
+ if !status.is_redirection() {
+ bail!(
+ "Confluence short URL did not redirect: {} {} {url}",
+ status.as_u16(),
+ status.canonical_reason().unwrap_or(""),
+ );
+ }
+
+ let location = response
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .ok_or_else(|| anyhow!("Redirect response missing Location header: {url}"))?
+ .to_owned();
+
+ validate_redirect_target(url, &location)
+}
+
+/// Resolves a `Location` against the URL it came from and refuses any target
+/// that could leak the token: non-HTTPS, embedded userinfo, or a different origin.
+fn validate_redirect_target(source: &Url, location: &str) -> Result {
+ let target = source
+ .join(location)
+ .with_context(|| format!("Invalid Location header: {location}"))?;
+
+ if target.scheme() != "https" {
+ bail!("Refusing to follow short URL redirect to a non-HTTPS target: {target}");
+ }
+ if !target.username().is_empty() || target.password().is_some() {
+ bail!("Refusing to follow short URL redirect containing userinfo: {target}");
+ }
+ if target.host_str() != source.host_str()
+ || target.port_or_known_default() != source.port_or_known_default()
+ {
+ bail!("Refusing to follow short URL redirect to a different origin: {target}");
+ }
+ Ok(target)
+}
+
// Helper for callers that need a configured HTTP client.
pub fn build_http_client() -> Result {
Client::builder()
- .user_agent("confluence2md/1.2.0")
+ .user_agent(USER_AGENT)
.build()
.context("build HTTP client")
}
@@ -668,7 +785,7 @@ pub(crate) fn _decode_attribute(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use wiremock::matchers::{method, path, query_param};
+ use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
@@ -725,6 +842,16 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn resolve_page_id_rejects_non_https_short_url() {
+ let client = Client::new();
+ let url = "http://confluence.example.com/x/AbCdEf";
+ let err = resolve_page_id_from_url(&client, url, "http://confluence.example.com", "token")
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("must use HTTPS"));
+ }
+
#[tokio::test]
async fn resolve_page_id_looks_up_via_api_for_space_and_title() {
let server = MockServer::start().await;
@@ -1482,4 +1609,226 @@ mod tests {
);
}
}
+
+ // ── Short URL expansion ────────────────────────────────────────
+
+ fn is_redirect_url(url: &str) -> bool {
+ Url::parse(url)
+ .map(|u| is_confluence_redirect_url(&u))
+ .unwrap_or(false)
+ }
+
+ #[test]
+ fn is_confluence_redirect_url_matches_x_key_path() {
+ assert!(is_redirect_url("https://confluence.example.com/x/AbCdEf"));
+ assert!(is_redirect_url("https://confluence.example.com/x/AbCdEf/"));
+ // Confluence Cloud's actual short-link format prefixes a `/wiki` context path.
+ assert!(is_redirect_url("https://site.atlassian.net/wiki/x/AbCdEf"));
+ }
+
+ #[test]
+ fn is_confluence_redirect_url_matches_tinyurl_action() {
+ assert!(is_redirect_url(
+ "https://confluence.example.com/pages/tinyurl.action?urlIdentifier=XYZ"
+ ));
+ assert!(is_redirect_url(
+ "https://site.atlassian.net/wiki/pages/tinyurl.action?urlIdentifier=XYZ"
+ ));
+ // tinyurl.action without urlIdentifier isn't the redirect endpoint.
+ assert!(!is_redirect_url(
+ "https://confluence.example.com/pages/tinyurl.action"
+ ));
+ }
+
+ #[test]
+ fn is_confluence_redirect_url_rejects_other_paths() {
+ assert!(!is_redirect_url(
+ "https://confluence.example.com/pages/viewpage.action?pageId=1"
+ ));
+ assert!(!is_redirect_url(
+ "https://confluence.example.com/x/AbC/extra"
+ ));
+ assert!(!is_redirect_url("not a url"));
+ }
+
+ #[tokio::test]
+ async fn resolve_confluence_redirects_rejects_non_https_source() {
+ // No mock server: the HTTPS check must reject before any request is sent.
+ let err = resolve_confluence_redirects("http://confluence.example.com/x/AbCdEf", "token")
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("must use HTTPS"));
+ }
+
+ #[tokio::test]
+ async fn resolve_confluence_redirects_passes_through_non_redirect_url() {
+ // Not a redirect-issuing path, so no request is made and no HTTPS check applies.
+ let url = resolve_confluence_redirects(
+ "http://confluence.example.com/pages/viewpage.action?pageId=1",
+ "token",
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ url,
+ "http://confluence.example.com/pages/viewpage.action?pageId=1"
+ );
+ }
+
+ // ── validate_redirect_target: pure, no network ──────────────────
+
+ #[test]
+ fn validate_redirect_target_accepts_relative_same_origin_location() {
+ let source = Url::parse("https://confluence.example.com/x/AbCdEf").unwrap();
+ let target =
+ validate_redirect_target(&source, "/pages/viewpage.action?pageId=123").unwrap();
+ assert_eq!(
+ target.as_str(),
+ "https://confluence.example.com/pages/viewpage.action?pageId=123"
+ );
+ }
+
+ #[test]
+ fn validate_redirect_target_rejects_scheme_relative_cross_origin() {
+ let source = Url::parse("https://confluence.example.com/x/AbCdEf").unwrap();
+ let err = validate_redirect_target(&source, "//evil.example.com/steal").unwrap_err();
+ assert!(err.to_string().contains("different origin"));
+ }
+
+ #[test]
+ fn validate_redirect_target_rejects_non_https() {
+ let source = Url::parse("https://confluence.example.com/x/AbCdEf").unwrap();
+ let err = validate_redirect_target(&source, "http://confluence.example.com/p?pageId=1")
+ .unwrap_err();
+ assert!(err.to_string().contains("non-HTTPS"));
+ }
+
+ #[test]
+ fn validate_redirect_target_rejects_userinfo() {
+ let source = Url::parse("https://confluence.example.com/x/AbCdEf").unwrap();
+ let err = validate_redirect_target(&source, "https://attacker:pw@evil.example.com/steal")
+ .unwrap_err();
+ assert!(err.to_string().contains("userinfo"));
+ }
+
+ #[test]
+ fn validate_redirect_target_rejects_same_host_different_port() {
+ let source = Url::parse("https://confluence.example.com/x/AbCdEf").unwrap();
+ let err =
+ validate_redirect_target(&source, "https://confluence.example.com:8443/p?pageId=1")
+ .unwrap_err();
+ assert!(err.to_string().contains("different origin"));
+ }
+
+ // ── follow_redirect_chain: network round trips over wiremock ────
+ //
+ // A validated redirect target must be HTTPS, so a real second hop can
+ // only be reached over genuine TLS — which wiremock (plain HTTP only)
+ // cannot serve. These tests therefore cover exactly one real hop each;
+ // that `is_confluence_redirect_url` also matches `tinyurl.action` (proven
+ // above) is what guarantees the loop keeps going instead of stopping
+ // there once a real chain runs against an actual Confluence server.
+
+ /// Starts a mock server that redirects `/x/AbCdEf` to `location`.
+ async fn short_url_redirecting_to(location: &str) -> (MockServer, Url) {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/x/AbCdEf"))
+ .respond_with(ResponseTemplate::new(302).insert_header("Location", location))
+ .mount(&server)
+ .await;
+ let short_url = Url::parse(&format!("{}/x/AbCdEf", server.uri())).unwrap();
+ (server, short_url)
+ }
+
+ #[tokio::test]
+ async fn follow_redirects_resolves_valid_https_same_origin_redirect() {
+ // wiremock only serves plain HTTP, so Location declares "https" on the
+ // same host:port to exercise the HTTPS + same-origin checks end-to-end.
+ let server = MockServer::start().await;
+ let https_authority = server.uri().replacen("http://", "https://", 1);
+ Mock::given(method("GET"))
+ .and(path("/x/AbCdEf"))
+ .and(header("Authorization", "Bearer token"))
+ .respond_with(ResponseTemplate::new(302).insert_header(
+ "Location",
+ format!("{https_authority}/pages/viewpage.action?pageId=123"),
+ ))
+ .mount(&server)
+ .await;
+
+ let short_url = Url::parse(&format!("{}/x/AbCdEf", server.uri())).unwrap();
+ let resolved = follow_redirect_chain(short_url, "Bearer token")
+ .await
+ .unwrap();
+ assert_eq!(
+ resolved,
+ format!("{https_authority}/pages/viewpage.action?pageId=123")
+ );
+ }
+
+ #[tokio::test]
+ async fn follow_redirects_continues_past_tinyurl_action_instead_of_stopping() {
+ let server = MockServer::start().await;
+ let https_authority = server.uri().replacen("http://", "https://", 1);
+ Mock::given(method("GET"))
+ .and(path("/x/AbCdEf"))
+ .respond_with(ResponseTemplate::new(302).insert_header(
+ "Location",
+ format!("{https_authority}/pages/tinyurl.action?urlIdentifier=XYZ"),
+ ))
+ .mount(&server)
+ .await;
+
+ // The https-labeled tinyurl.action target isn't actually TLS-capable
+ // (wiremock only speaks plain HTTP), so a genuine second request
+ // attempt fails at the transport layer. That failure is exactly what
+ // proves the loop didn't stop and return the tinyurl.action URL as
+ // final — it tried to follow it.
+ let short_url = Url::parse(&format!("{}/x/AbCdEf", server.uri())).unwrap();
+ let err = follow_redirect_chain(short_url, "token").await.unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("HTTP request failed"), "{msg}");
+ assert!(
+ msg.contains("tinyurl.action"),
+ "second hop was not attempted: {msg}"
+ );
+ }
+
+ #[tokio::test]
+ async fn follow_redirects_rejects_cross_origin_target() {
+ let (_server, short_url) =
+ short_url_redirecting_to("https://evil.example.com/pages/viewpage.action?pageId=123")
+ .await;
+ let err = follow_redirect_chain(short_url, "token").await.unwrap_err();
+ assert!(err.to_string().contains("different origin"));
+ }
+
+ #[tokio::test]
+ async fn follow_redirects_rejects_redirect_without_location() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/x/AbCdEf"))
+ .respond_with(ResponseTemplate::new(302))
+ .mount(&server)
+ .await;
+
+ let short_url = Url::parse(&format!("{}/x/AbCdEf", server.uri())).unwrap();
+ let err = follow_redirect_chain(short_url, "token").await.unwrap_err();
+ assert!(err.to_string().contains("missing Location header"));
+ }
+
+ #[tokio::test]
+ async fn follow_redirects_rejects_non_redirect_response() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/x/AbCdEf"))
+ .respond_with(ResponseTemplate::new(200).set_body_string("not a redirect"))
+ .mount(&server)
+ .await;
+
+ let short_url = Url::parse(&format!("{}/x/AbCdEf", server.uri())).unwrap();
+ let err = follow_redirect_chain(short_url, "token").await.unwrap_err();
+ assert!(err.to_string().contains("did not redirect"));
+ }
}