use askama::Template; use askama_web::WebTemplate; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::Form; use k8s_openapi::api::core::v1::Secret; use kube::api::{ListParams, PostParams}; use kube::Api; use serde::Deserialize; use crate::auth_guard::AuthUser; use crate::ergo_admin; use crate::flash; use crate::irccloud; use crate::k8s::{SojuBouncer, sanitize_bouncer_name, default_bouncer_spec}; use crate::page::PageContext; use crate::state::AppState; #[derive(Template, WebTemplate)] #[template(path = "migrate.html")] pub struct MigrateFormTemplate { pub page: PageContext, pub is_admin: bool, } #[derive(Template, WebTemplate)] #[template(path = "migrate_result.html")] pub struct MigrateResultTemplate { pub page: PageContext, pub is_admin: bool, pub success: bool, pub message: String, pub bouncer_name: String, pub networks: Vec, } pub struct NetworkResult { pub name: String, pub addr: String, pub channel_count: usize, } #[derive(Deserialize)] pub struct MigrateForm { pub email: String, pub password: String, } pub async fn form( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = MigrateFormTemplate { page, is_admin: user.is_admin == Some(true) }; if clear { (axum::response::AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } pub async fn run_migration( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(form): Form, ) -> impl IntoResponse { let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = match do_migration(&state, &user, &form).await { Ok(mut template) => { template.page = page; template } Err(msg) => MigrateResultTemplate { page, success: false, message: msg, bouncer_name: String::new(), networks: vec![], }, }; if clear { (axum::response::AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } async fn do_migration( state: &AppState, user: &irc_now_common::auth::UserClaims, form: &MigrateForm, ) -> Result { let session = irccloud::authenticate(&form.email, &form.password) .await .map_err(|e| e.to_string())?; let servers = irccloud::fetch_state(&session) .await .map_err(|e| e.to_string())?; if servers.is_empty() { return Ok(MigrateResultTemplate { page: PageContext { flash: None, announcement: None }, success: true, message: "no connections found on irccloud".to_string(), bouncer_name: String::new(), networks: vec![], }); } let bouncer_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let lp = ListParams::default().labels(&format!("irc.now/owner={}", user.sub)); let existing = bouncer_api.list(&lp).await.map_err(|e| e.to_string())?; let bouncer_name = if let Some(b) = existing.items.first() { b.metadata.name.clone().unwrap_or_default() } else { let raw_name = if let Some(ref name) = user.display_name { name.as_str() } else if let Some(ref email) = user.email { email.split('@').next().unwrap_or("user") } else { "user" }; let name = sanitize_bouncer_name(raw_name); let name = if name.is_empty() { "user".to_string() } else { name }; let mut bouncer = SojuBouncer::new( &name, default_bouncer_spec(&name, &user.sub, &state.oidc.issuer_url), ); bouncer .metadata .labels .get_or_insert_with(Default::default) .insert("irc.now/owner".to_string(), user.sub.clone()); bouncer_api .create(&PostParams::default(), &bouncer) .await .map_err(|e| format!("failed to create bouncer: {e}"))?; let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret_name = format!("{name}-db"); let mut attempts = 0; loop { match secret_api.get(&secret_name).await { Ok(_) => break, Err(_) if attempts < 15 => { attempts += 1; tokio::time::sleep(std::time::Duration::from_secs(2)).await; } Err(e) => return Err(format!("timed out waiting for bouncer database: {e}")), } } name }; let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api .get(&format!("{bouncer_name}-db")) .await .map_err(|e| format!("failed to get db secret: {e}"))?; let uri = secret .data .as_ref() .and_then(|d| d.get("uri")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing uri in db secret")?; let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls) .await .map_err(|e| format!("failed to connect to soju db: {e}"))?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::warn!("soju db connection error: {e}"); } }); let user_row = client .query_one(r#"SELECT id, username FROM "User" LIMIT 1"#, &[]) .await .map_err(|e| format!("failed to get soju user: {e}"))?; let soju_user_id: i64 = user_row.get(0); let soju_username: String = user_row.get(1); let mut network_results = Vec::new(); let mut total_channels = 0usize; for server in &servers { let addr = server.addr(); let connect_commands = server.connect_commands(); let row = client .query_opt( r#"INSERT INTO "Network" ("user", name, addr, nick, realname, pass, connect_commands, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, true) ON CONFLICT ("user", name) DO NOTHING RETURNING id"#, &[ &soju_user_id, &server.name, &addr, &server.nick, &server.realname, &server.server_pass.as_deref().unwrap_or(""), &connect_commands, ], ) .await .map_err(|e| format!("failed to insert network {}: {e}", server.name))?; if let Some(row) = row { let network_id: i64 = row.get(0); for channel in &server.channels { client .execute( r#"INSERT INTO "Channel" (network, name) VALUES ($1, $2) ON CONFLICT (network, name) DO NOTHING"#, &[&network_id, channel], ) .await .map_err(|e| format!("failed to insert channel {channel}: {e}"))?; } total_channels += server.channels.len(); network_results.push(NetworkResult { name: server.name.clone(), addr, channel_count: server.channels.len(), }); } } let ergo_password = ergo_admin::generate_password(); if let Err(e) = ergo_admin::ensure_ergo_account( &state.kube, &state.namespace, &soju_username, &ergo_password, ) .await { tracing::warn!("ergo account registration failed for {soju_username}: {e}"); } client .execute( r#"INSERT INTO "Network" ("user", name, addr, nick, enabled, connect_commands, sasl_mechanism, sasl_plain_username, sasl_plain_password) SELECT $1, 'irc.now', 'irc+insecure://irc-now-net:6667', username, true, '', 'PLAIN', username, $2 FROM "User" WHERE id = $1 ON CONFLICT DO NOTHING"#, &[&soju_user_id, &ergo_password], ) .await .ok(); Ok(MigrateResultTemplate { page: PageContext { flash: None, announcement: None }, success: true, message: format!( "imported {} networks and {} channels into bouncer \"{}\"", network_results.len(), total_channels, bouncer_name, ), bouncer_name, networks: network_results, }) }