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
155 changes: 142 additions & 13 deletions apps/server/src/pr_previews.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
//!
//! Sécurité :
//! - PR de forks ignorées (code non fiable + variables du projet) : Check Run `neutral`.
//! - Les variables d'environnement de prod ne sont PAS injectées : seules celles marquées
//! « preview » (`project_env_vars.preview = true`) le sont, plus `DEVFORGE_PREVIEW=1`.
//! - Env runtime = variables du projet (comme la prod, sinon l'app ne démarre pas),
//! `PREVIEW_<CLÉ>` remplaçant `<CLÉ>` (ex. base de preview séparée), URI de
//! redirection OAuth réécrites vers l'hôte de la preview, plus `DEVFORGE_PREVIEW=1`.
//! Sans `PREVIEW_TURSO_DATABASE_URL` (ou autre base), la preview utilise la base de prod.
//! - Ressources limitées (`DEVFORGE_PREVIEW_MEMORY`, défaut 1g ; `DEVFORGE_PREVIEW_CPUS`,
//! défaut 1), pas de ports publiés sur l'hôte, pas de volumes de la prod.
//! - Commentaire PR unique par projet (marqueur caché), édité à chaque changement.
Expand Down Expand Up @@ -339,25 +341,152 @@ async fn set_status(pool: &sqlx::PgPool, project_uuid: &str, pr: i64, status: &s
.await;
}

/// Variables marquées « preview » uniquement (jamais les secrets de prod par défaut).
/// Préfixe des variables projet qui ne s'appliquent qu'aux previews :
/// `PREVIEW_TURSO_DATABASE_URL` remplace `TURSO_DATABASE_URL` dans une preview
/// (base de preview séparée), et n'est jamais injectée en prod sous son nom.
pub const PREVIEW_OVERRIDE_PREFIX: &str = "PREVIEW_";

/// Variables dont la base de données doit être isolée : sans override
/// `PREVIEW_<clé>`, la preview partage la base de prod (avertissement).
const DB_KEYS: &[&str] = &[
"TURSO_DATABASE_URL",
"DATABASE_URL",
"LIBSQL_URL",
"POSTGRES_URL",
"MONGODB_URI",
"REDIS_URL",
];

/// Env runtime d'une preview, construit depuis les variables du projet :
/// 1. toutes les variables du projet (même runtime que la prod : sans elles,
/// l'app ne démarre pas) ;
/// 2. `PREVIEW_<CLÉ>` remplace `<CLÉ>` (et n'est pas transmise telle quelle) ;
/// 3. les URI de redirection OAuth/OIDC (`*_REDIRECT_URI` / `*_REDIRECT_URL`) et
/// les URL qui visent l'hôte de prod (`AUTH_URL`, `NEXTAUTH_URL`…), non
/// surchargées, pointent vers l'hôte de la preview (même chemin) ;
/// 4. `DEVFORGE_PREVIEW*` fixées par DevForge.
pub fn build_preview_env(
rows: &[(String, String)],
pr: i64,
url: &str,
prod_host: Option<&str>,
) -> Vec<(String, String)> {
use std::collections::BTreeMap;
let mut base: BTreeMap<String, String> = BTreeMap::new();
let mut overrides: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in rows {
if k.starts_with("DEVFORGE_PREVIEW") {
continue;
}
match k.strip_prefix(PREVIEW_OVERRIDE_PREFIX) {
Some(target) if !target.is_empty() => {
overrides.insert(target.to_string(), v.clone());
}
_ => {
base.insert(k.clone(), v.clone());
}
}
}
let preview_host = crate::routes::fqdn_from_url(url);
for (k, v) in base.iter_mut() {
if overrides.contains_key(k) {
continue;
}
let targets_prod = prod_host.is_some_and(|h| {
crate::routes::fqdn_from_url(v.trim()).is_some_and(|vh| vh.split(':').next() == Some(h))
});
if !is_redirect_uri_key(k) && !targets_prod {
continue;
}
if let Some(host) = preview_host.as_deref() {
if let Some(rewritten) = rewrite_url_host(v, host) {
*v = rewritten;
}
}
}
base.extend(overrides);
base.retain(|k, _| !k.starts_with("DEVFORGE_PREVIEW"));
let mut out: Vec<(String, String)> = base.into_iter().collect();
out.push(("DEVFORGE_PREVIEW".into(), "1".into()));
out.push(("DEVFORGE_PREVIEW_PR".into(), pr.to_string()));
out.push(("DEVFORGE_PREVIEW_URL".into(), url.to_string()));
out
}

fn is_redirect_uri_key(key: &str) -> bool {
let k = key.to_ascii_uppercase();
k.ends_with("REDIRECT_URI") || k.ends_with("REDIRECT_URL")
}

/// `https://prod.example.com/auth/cb?x=1` → `https://<host>/auth/cb?x=1`
/// (schéma, chemin et requête conservés ; identifiants et port retirés).
/// `None` si la valeur n'est pas une URL http(s).
pub fn rewrite_url_host(value: &str, host: &str) -> Option<String> {
let v = value.trim();
let (scheme, rest) = v.split_once("://")?;
if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") {
return None;
}
let cut = rest.find(['/', '?', '#']).unwrap_or(rest.len());
if rest[..cut].is_empty() {
return None;
}
// Les previews sont servies en HTTPS derrière le proxy.
Some(format!("https://{host}{}", &rest[cut..]))
}

/// Clés de base de données de la prod partagées par la preview faute
/// d'override `PREVIEW_<clé>` (noms seulement).
pub fn preview_shared_db_keys(rows: &[(String, String)]) -> Vec<String> {
DB_KEYS
.iter()
.filter(|k| {
rows.iter()
.any(|(rk, rv)| rk == *k && !rv.trim().is_empty())
&& !rows.iter().any(|(rk, rv)| {
rk.strip_prefix(PREVIEW_OVERRIDE_PREFIX) == Some(**k) && !rv.trim().is_empty()
})
})
.map(|k| k.to_string())
.collect()
}

/// Env runtime de la preview (voir [`build_preview_env`]), au format `--env-file`.
pub async fn load_preview_env_file(
pool: &sqlx::PgPool,
project_uuid: &str,
project: &Project,
pr: i64,
url: &str,
) -> Option<String> {
let mut rows: Vec<(String, String)> = sqlx::query_as(
"SELECT key, value FROM project_env_vars WHERE project_uuid = $1 AND preview = true ORDER BY key",
let project_uuid = project.uuid.as_str();
let rows: Vec<(String, String)> = match sqlx::query_as(
"SELECT key, value FROM project_env_vars WHERE project_uuid = $1 ORDER BY key",
)
.bind(project_uuid)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.retain(|(k, _)| !k.starts_with("DEVFORGE_PREVIEW"));
rows.push(("DEVFORGE_PREVIEW".into(), "1".into()));
rows.push(("DEVFORGE_PREVIEW_PR".into(), pr.to_string()));
rows.push(("DEVFORGE_PREVIEW_URL".into(), url.to_string()));
Some(devforge_env::serialize_docker_env_file(&rows))
{
Ok(r) => r,
Err(e) => {
tracing::warn!(project_uuid, error = %e, "preview env: lecture des variables");
Vec::new()
}
};
let shared = preview_shared_db_keys(&rows);
if !shared.is_empty() {
tracing::warn!(
project_uuid,
pr,
keys = %shared.join(","),
"preview: base de prod partagée (définir PREVIEW_<clé> pour l'isoler)"
);
}
let prod_host = project
.production_url
.as_deref()
.and_then(crate::routes::fqdn_from_url);
let env = build_preview_env(&rows, pr, url, prod_host.as_deref());
Some(devforge_env::serialize_docker_env_file(&env))
}

/// Projets liés à l'installation et au dépôt de l'événement, previews activées.
Expand Down Expand Up @@ -740,7 +869,7 @@ async fn run_preview_deploy(
.map(|(t,)| crate::secret_box::open_compat(&t))
.filter(|t| !t.trim().is_empty());
let deploy_tok = crate::github_auth::deploy_token(state, &project.uuid, pat).await;
let env_file = load_preview_env_file(&state.pool, &project.uuid, pr, url).await;
let env_file = load_preview_env_file(&state.pool, project, pr, url).await;
let current = project
.server_id
.clone()
Expand Down
157 changes: 157 additions & 0 deletions apps/server/src/pr_previews_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,160 @@ mod comment_api {
);
}
}

fn env_rows(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}

fn env_get<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}

const PV_URL: &str = "https://pr-6-vigie.jeser.app";

#[test]
fn preview_env_carries_project_vars_like_prod() {
let rows = env_rows(&[
("TURSO_DATABASE_URL", "libsql://prod.turso.io"),
("TURSO_AUTH_TOKEN", "prod-token"),
("OIDC_ISSUER", "https://id.example.com"),
("OIDC_CLIENT_ID", "vigie"),
("OIDC_CLIENT_SECRET", "s3cret"),
]);
let env = build_preview_env(&rows, 6, PV_URL, Some("vigie.jeser.app"));
assert_eq!(
env_get(&env, "TURSO_DATABASE_URL"),
Some("libsql://prod.turso.io")
);
assert_eq!(env_get(&env, "TURSO_AUTH_TOKEN"), Some("prod-token"));
assert_eq!(env_get(&env, "OIDC_ISSUER"), Some("https://id.example.com"));
assert_eq!(env_get(&env, "OIDC_CLIENT_ID"), Some("vigie"));
assert_eq!(env_get(&env, "OIDC_CLIENT_SECRET"), Some("s3cret"));
assert_eq!(env_get(&env, "DEVFORGE_PREVIEW"), Some("1"));
assert_eq!(env_get(&env, "DEVFORGE_PREVIEW_PR"), Some("6"));
assert_eq!(env_get(&env, "DEVFORGE_PREVIEW_URL"), Some(PV_URL));
assert_eq!(
preview_shared_db_keys(&rows),
vec!["TURSO_DATABASE_URL".to_string()]
);
}

#[test]
fn preview_overrides_replace_prod_values() {
let rows = env_rows(&[
("TURSO_DATABASE_URL", "libsql://prod.turso.io"),
("TURSO_AUTH_TOKEN", "prod-token"),
("PREVIEW_TURSO_DATABASE_URL", "libsql://preview.turso.io"),
("PREVIEW_TURSO_AUTH_TOKEN", "preview-token"),
("PREVIEW_ONLY_FLAG", "on"),
]);
let env = build_preview_env(&rows, 6, PV_URL, Some("vigie.jeser.app"));
assert_eq!(
env_get(&env, "TURSO_DATABASE_URL"),
Some("libsql://preview.turso.io")
);
assert_eq!(env_get(&env, "TURSO_AUTH_TOKEN"), Some("preview-token"));
assert_eq!(env_get(&env, "ONLY_FLAG"), Some("on"));
assert!(env.iter().all(|(k, _)| !k.starts_with("PREVIEW_")));
assert!(preview_shared_db_keys(&rows).is_empty());
// Override vide : la base n'est pas considérée comme isolée.
let empty = env_rows(&[
("TURSO_DATABASE_URL", "libsql://prod.turso.io"),
("PREVIEW_TURSO_DATABASE_URL", " "),
]);
assert_eq!(
preview_shared_db_keys(&empty),
vec!["TURSO_DATABASE_URL".to_string()]
);
}

#[test]
fn preview_redirect_uris_point_to_preview_host() {
let rows = env_rows(&[
(
"OIDC_REDIRECT_URI",
"https://vigie.jeser.app/api/auth/callback?x=1",
),
(
"AUTH_POCKET_ID_REDIRECT_URL",
"https://vigie.jeser.app:443/cb",
),
("NOT_A_REDIRECT", "https://vigie.jeser.app/keep"),
("WEIRD_REDIRECT_URI", "not a url"),
]);
let env = build_preview_env(&rows, 6, PV_URL, Some("vigie.jeser.app"));
assert_eq!(
env_get(&env, "OIDC_REDIRECT_URI"),
Some("https://pr-6-vigie.jeser.app/api/auth/callback?x=1")
);
assert_eq!(
env_get(&env, "AUTH_POCKET_ID_REDIRECT_URL"),
Some("https://pr-6-vigie.jeser.app/cb")
);
// URL visant l'hôte de prod : réécrite aussi ; un autre hôte est conservé.
assert_eq!(
env_get(&env, "NOT_A_REDIRECT"),
Some("https://pr-6-vigie.jeser.app/keep")
);
let other = env_rows(&[
("AUTH_URL", "https://vigie.jeser.app"),
("OIDC_ISSUER", "https://id.jeser.app"),
("NEXTAUTH_URL", "https://vigie.jeser.app/"),
]);
let env2 = build_preview_env(&other, 6, PV_URL, Some("vigie.jeser.app"));
assert_eq!(
env_get(&env2, "AUTH_URL"),
Some("https://pr-6-vigie.jeser.app")
);
assert_eq!(
env_get(&env2, "NEXTAUTH_URL"),
Some("https://pr-6-vigie.jeser.app/")
);
assert_eq!(env_get(&env2, "OIDC_ISSUER"), Some("https://id.jeser.app"));
let env3 = build_preview_env(&other, 6, PV_URL, None);
assert_eq!(env_get(&env3, "AUTH_URL"), Some("https://vigie.jeser.app"));
assert_eq!(env_get(&env, "WEIRD_REDIRECT_URI"), Some("not a url"));
// Un override explicite gagne sur la réécriture.
let rows = env_rows(&[
("OIDC_REDIRECT_URI", "https://vigie.jeser.app/cb"),
("PREVIEW_OIDC_REDIRECT_URI", "https://fixed.example.com/cb"),
]);
let env = build_preview_env(&rows, 6, PV_URL, Some("vigie.jeser.app"));
assert_eq!(
env_get(&env, "OIDC_REDIRECT_URI"),
Some("https://fixed.example.com/cb")
);
}

#[test]
fn preview_env_cannot_spoof_devforge_markers() {
let rows = env_rows(&[
("DEVFORGE_PREVIEW", "0"),
("PREVIEW_DEVFORGE_PREVIEW_PR", "999"),
]);
let env = build_preview_env(&rows, 6, PV_URL, Some("vigie.jeser.app"));
let markers: Vec<_> = env
.iter()
.filter(|(k, _)| k.starts_with("DEVFORGE_PREVIEW"))
.collect();
assert_eq!(markers.len(), 3);
assert_eq!(env_get(&env, "DEVFORGE_PREVIEW"), Some("1"));
assert_eq!(env_get(&env, "DEVFORGE_PREVIEW_PR"), Some("6"));
}

#[test]
fn rewrite_url_host_keeps_path_and_query() {
assert_eq!(
rewrite_url_host("http://u:p@a.example.com:8080/x/y?z=1#f", "b.example.com").as_deref(),
Some("https://b.example.com/x/y?z=1#f")
);
assert_eq!(
rewrite_url_host("https://a.example.com", "b.example.com").as_deref(),
Some("https://b.example.com")
);
assert_eq!(rewrite_url_host("ftp://a/x", "b"), None);
assert_eq!(rewrite_url_host("https:///x", "b"), None);
}
Loading