diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 4a366fdaa..29a4dbb5d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => { expect(await screen.findByText('构建并发布一个分支')).toBeTruthy(); }); +test('prefers the public preview domain when the control plane exposes one', async () => { + vi.mocked(api.listDeployments).mockResolvedValue([ + { + id: '77', + branch: 'feature/public-preview', + resolvedCommit: '1234567890abcdef', + status: 'running', + health: 'healthy', + webPort: 8400, + webUrl: 'http://192.168.35.82:8400', + webPublicUrl: + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + createdAt: 1_787_270_400, + updatedAt: 1_787_270_460, + }, + ]); + render(); + + const publicLink = await screen.findByRole('link', { + name: /打开公网预览/u, + }); + expect(publicLink.getAttribute('href')).toBe( + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + ); +}); + test('submits a branch with an optional commit hash', async () => { const user = userEvent.setup(); render(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index ec5e30773..ea23c3836 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? ( + + 打开公网预览 + + ) : null} {deployment.webUrl ? ( .<后缀>,留空则只展示内网地址。 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs index f693697bb..ff8d637ab 100644 --- a/server-rs/crates/preview-deployer-server/src/config.rs +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub allowed_hosts: Vec, pub allowed_origins: Vec, pub preview_web_host: String, + pub preview_web_domain: Option, pub secure_cookie: bool, pub static_dir: Option, pub state_file: PathBuf, @@ -49,6 +50,7 @@ impl fmt::Debug for Config { .field("allowed_hosts", &self.allowed_hosts) .field("allowed_origins", &self.allowed_origins) .field("preview_web_host", &self.preview_web_host) + .field("preview_web_domain", &self.preview_web_domain) .field("secure_cookie", &self.secure_cookie) .field("static_dir", &self.static_dir) .field("state_file", &self.state_file) @@ -116,6 +118,15 @@ impl Config { .to_string(), ); } + let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| { + validate_preview_web_domain(&value) + .map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}")) + }) + .transpose()?; for origin in &allowed_origins { let parsed = Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; @@ -162,6 +173,7 @@ impl Config { allowed_hosts, allowed_origins, preview_web_host, + preview_web_domain, secure_cookie, static_dir, state_file, @@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_preview_web_domain(value: &str) -> Result { + if value.len() > 253 || !value.contains('.') { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + for label in value.split('.') { + if label.is_empty() || label.len() > 63 { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + let bytes = label.as_bytes(); + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return Err("每段标签不能以短横线开头或结尾".to_string()); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') + { + return Err("只允许小写字母、数字、短横线和点号".to_string()); + } + } + Ok(value.to_string()) +} + fn required(name: &str) -> Result { env::var(name) .ok() diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 223255b1b..e0165799b 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -115,6 +115,8 @@ pub struct Deployment { pub web_port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_public_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub jenkins_build_url: Option, pub created_at: u64, @@ -633,6 +635,7 @@ async fn create_deployment( health: HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { if let Some(value) = result.web_port { record.public.web_port = Some(value); } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url( + state.config.preview_web_domain.as_deref(), + &record.instance_id, + ) + } else { + None + }; if let Some(value) = result.health { record.public.health = value; } @@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { record.public.health = HealthStatus::Unknown; record.public.web_port = None; record.public.web_url = None; + record.public.web_public_url = None; record.public.can_uninstall = false; if record.public.message.is_none() { record.public.message = Some("预览实例已卸载".to_string()); @@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result if record.public.web_port.is_none() { record.public.web_port = url_port; } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url(config.preview_web_domain.as_deref(), &record.instance_id) + } else { + None + }; if deployments .insert(record.instance_id.clone(), record) .is_some() @@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option { } } +// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」, +// 不接受 Jenkins 产物或状态文件提供的任意地址。 +fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option { + let domain = domain?; + if validate_deployment_id(instance_id).is_err() { + return None; + } + Some(format!("https://{instance_id}.{domain}")) +} + fn is_safe_web_url(value: &str, expected_host: &str) -> bool { url::Url::parse(value).is_ok_and(|url| { url.scheme() == "http" diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 98b2b5511..7874d5169 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config { allowed_hosts: vec![HOST.to_string()], allowed_origins: vec![ORIGIN.to_string()], preview_web_host: "192.168.35.82".to_string(), + preview_web_domain: Some("preview.genarrative.world".to_string()), secure_cookie: false, static_dir: None, state_file: std::env::temp_dir().join(format!( @@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { ); assert_eq!(deployment.health, super::HealthStatus::Healthy); assert_eq!(deployment.web_port, Some(8400)); + assert_eq!( + deployment.web_public_url.as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); assert_eq!( deployment.web_url.as_deref(), Some("http://192.168.35.82:8400") @@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { assert_eq!(deployment.health, super::HealthStatus::Unknown); assert_eq!(deployment.web_port, None); assert_eq!(deployment.web_url, None); + assert_eq!(deployment.web_public_url, None); assert!(!deployment.can_uninstall); let list_request = axum::http::Request::builder() @@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { health: super::HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -652,6 +659,40 @@ async fn wait_for_status( Err(()) } +#[test] +fn public_web_url_is_derived_from_instance_id_and_configured_domain() { + assert_eq!( + super::public_web_url( + Some("preview.genarrative.world"), + "preview-63d38d3da6bc9b06" + ) + .as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); + assert_eq!( + super::public_web_url(None, "preview-63d38d3da6bc9b06"), + None + ); + assert_eq!( + super::public_web_url(Some("preview.genarrative.world"), "feature/demo"), + None + ); +} + +#[test] +fn preview_web_domain_config_is_strict() { + assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok()); + assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err()); + assert!( + super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err() + ); + assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err()); + assert!(super::config::validate_preview_web_domain("preview").is_err()); + assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("").is_err()); +} + #[test] fn branch_commit_and_web_url_validation_are_strict() { assert!(super::validate_branch("feature/preview-ui").is_ok()); @@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, @@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: Some(8401), web_url: Some("http://192.168.35.82:8401".to_string()), + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old,