diff --git a/src/share/publish.rs b/src/share/publish.rs index 577391a..9addce3 100644 --- a/src/share/publish.rs +++ b/src/share/publish.rs @@ -17,6 +17,15 @@ const PROVIDER_CLOUDFLARE_PAGES: &str = "cloudflare-pages"; const PAGES_PROJECT_NAME_FIELD: &str = "Project Name"; const PAGES_PROJECT_DOMAINS_FIELD: &str = "Project Domains"; const MAX_PAGES_ASSET_BYTES: usize = 25 * 1024 * 1024; +const PUBLISH_DIR_MARKER: &str = ".recall-share"; +const PUBLISH_DIR_MARKER_CONTENT: &str = "Recall-managed share directory\n"; +const LEGACY_HEADERS: &str = "/*\n X-Robots-Tag: noindex, nofollow\n X-Frame-Options: DENY\n X-Content-Type-Options: nosniff\n Referrer-Policy: no-referrer\n"; +const SHARE_HTML_PREFIX: &str = ""; +const SHARE_HTML_SIGNATURES: [&str; 3] = [ + "body{margin:0;background:#f6f7f9;color:#17181c;font:15px/1.6", + "--page-bg: #F5F5F7;\n --content-bg: #FFFFFF;", + "--page-bg:#FAF9F6;--surface:#FFFFFF;--user-surface:#FFFFFF;", +]; #[derive(Debug, Clone)] pub(crate) struct SharePreview { @@ -237,11 +246,63 @@ fn configured_project_domain(share: &ShareConfig) -> Result { fn init_publish_dir(publish_dir: &Path) -> Result<()> { fs::create_dir_all(publish_dir) .with_context(|| format!("failed to create {}", publish_dir.display()))?; + let marker = publish_dir.join(PUBLISH_DIR_MARKER); + let managed = + fs::read_to_string(&marker).is_ok_and(|contents| contents == PUBLISH_DIR_MARKER_CONTENT); + if publish_dir_is_unmanaged(publish_dir, managed)? { + bail!( + "publish directory {} is not managed by Recall; choose an empty directory", + publish_dir.display() + ); + } + if !managed { + fs::write(&marker, PUBLISH_DIR_MARKER_CONTENT)?; + } fs::write(publish_dir.join("_headers"), HEADERS)?; fs::write(publish_dir.join("robots.txt"), ROBOTS)?; Ok(()) } +fn publish_dir_is_unmanaged(publish_dir: &Path, managed: bool) -> Result { + let mut has_entries = false; + let mut has_headers = false; + let mut has_robots = false; + for entry in fs::read_dir(publish_dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + return Ok(true); + } + has_entries = true; + match entry.file_name().to_str() { + Some(PUBLISH_DIR_MARKER) if managed => {} + Some("_headers") => { + let contents = fs::read_to_string(entry.path())?; + if contents != HEADERS && contents != LEGACY_HEADERS { + return Ok(true); + } + has_headers = true; + } + Some("robots.txt") => { + if fs::read_to_string(entry.path())? != ROBOTS { + return Ok(true); + } + has_robots = true; + } + Some(name) if name.ends_with(".html") => { + let html = fs::read_to_string(entry.path())?; + if !html.starts_with(SHARE_HTML_PREFIX) + || !SHARE_HTML_SIGNATURES.iter().any(|signature| html.contains(signature)) + || !html.ends_with("") + { + return Ok(true); + } + } + _ => return Ok(true), + } + } + Ok(!managed && has_entries && !(has_headers && has_robots)) +} + fn ensure_wrangler_available() -> Result<()> { let output = wrangler_command()? .arg("--version") @@ -429,6 +490,43 @@ mod tests { assert_eq!(share_id_for_session(&session("foo/bar baz")), "foo-bar-baz"); } + #[test] + fn publish_dir_rejects_unmanaged_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("canary-secret"), "do not publish").unwrap(); + + let error = init_publish_dir(dir.path()).unwrap_err(); + + assert!(error.to_string().contains("not managed by Recall")); + assert!(!dir.path().join("_headers").exists()); + assert!(!dir.path().join("robots.txt").exists()); + + let spoofed = tempfile::tempdir().unwrap(); + fs::write(spoofed.path().join(PUBLISH_DIR_MARKER), "not Recall").unwrap(); + assert!(init_publish_dir(spoofed.path()).is_err()); + + let managed = tempfile::tempdir().unwrap(); + init_publish_dir(managed.path()).unwrap(); + assert!(managed.path().join(PUBLISH_DIR_MARKER).is_file()); + init_publish_dir(managed.path()).unwrap(); + fs::write(managed.path().join("later-canary"), "do not publish").unwrap(); + assert!(init_publish_dir(managed.path()).is_err()); + + let legacy = tempfile::tempdir().unwrap(); + fs::write(legacy.path().join("_headers"), HEADERS).unwrap(); + fs::write(legacy.path().join("robots.txt"), ROBOTS).unwrap(); + let session = session("legacy-session"); + let meta = collect_session_display_meta(&session, &[]); + fs::write( + legacy.path().join("legacy-session.html"), + render_session_html(&session, &[], &meta), + ) + .unwrap(); + init_publish_dir(legacy.path()).unwrap(); + assert!(legacy.path().join(PUBLISH_DIR_MARKER).is_file()); + init_publish_dir(legacy.path()).unwrap(); + } + fn session(source_id: &str) -> Session { Session { id: "local-id".to_string(),