use actix_web::{HttpRequest, HttpResponse, Responder, web};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::session::SessionStore;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExportedConfig {
pub name: String,
pub url: String,
pub pin: String,
pub api_key: String,
pub active: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExportConfigQuery {
pub url: Option<String>,
pub name: Option<String>,
}
pub fn resolve_server_url(req: &HttpRequest, query_url: Option<&str>) -> String {
if let Some(url) = query_url.map(|s| s.trim()).filter(|s| !s.is_empty()) {
return url.trim_end_matches('/').to_string();
}
if let Ok(url) = std::env::var("NOTIFY_URL").or_else(|_| std::env::var("SERVER_URL")) {
let trimmed = url.trim().trim_end_matches('/');
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
let conn = req.connection_info();
let scheme = conn.scheme();
let host = conn.host();
let resolved_host = if host.starts_with("0.0.0.0") {
host.replacen("0.0.0.0", "127.0.0.1", 1)
} else {
host.to_string()
};
format!("{scheme}://{resolved_host}")
.trim_end_matches('/')
.to_string()
}
pub async fn export_config(
req: HttpRequest,
session_store: web::Data<SessionStore>,
query: web::Query<ExportConfigQuery>,
config: web::Data<Config>,
) -> impl Responder {
let is_session_auth = session_store.is_authenticated(&req);
let is_pin_auth = crate::auth::extract_pin(&req)
.map(|k| config.is_valid_pin(&k))
.unwrap_or(false);
if !is_session_auth && !is_pin_auth {
return HttpResponse::Unauthorized().finish();
}
let raw_name = query.name.as_deref().unwrap_or("notify").trim();
let name = if raw_name.is_empty() {
"notify"
} else {
raw_name
};
let url = resolve_server_url(&req, query.url.as_deref());
let exported = ExportedConfig {
name: name.to_string(),
url,
pin: config.pin.clone(),
api_key: config.pin.clone(),
active: name.to_string(),
};
let json_body = match serde_json::to_string_pretty(&exported) {
Ok(s) => s + "\n",
Err(e) => {
log::error!("Failed to serialize exported config: {e}");
return HttpResponse::InternalServerError().finish();
}
};
let filename = format!("{name}.json");
HttpResponse::Ok()
.content_type("application/json; charset=utf-8")
.insert_header((
actix_web::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
))
.insert_header((
actix_web::http::header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate",
))
.body(json_body)
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header::HeaderValue;
use actix_web::{App, test as aw_test};
fn test_config() -> Config {
Config::new(
8080,
HeaderValue::from_static(crate::config::DEFAULT_CACHE_CONTROL),
"notify.db".to_string(),
false,
"123456".to_string(),
)
}
#[test]
fn test_resolve_server_url_with_query_override() {
let req = aw_test::TestRequest::default().to_http_request();
let url = resolve_server_url(&req, Some("https://example.com/notify/"));
assert_eq!(url, "https://example.com/notify");
}
#[test]
fn test_resolve_server_url_replaces_0000() {
let req = aw_test::TestRequest::default()
.insert_header(("Host", "0.0.0.0:8080"))
.to_http_request();
let url = resolve_server_url(&req, None);
assert_eq!(url, "http://127.0.0.1:8080");
}
#[test]
fn test_resolve_server_url_preserves_lan_host() {
let req = aw_test::TestRequest::default()
.insert_header(("Host", "192.168.1.55:8080"))
.to_http_request();
let url = resolve_server_url(&req, None);
assert_eq!(url, "http://192.168.1.55:8080");
}
#[actix_web::test]
async fn test_export_config_endpoint_unauthorized() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(crate::session::SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/config/export", web::get().to(export_config)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/config/export")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn test_export_config_endpoint_authorized_with_pin() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(crate::session::SessionStore::in_memory());
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/config/export", web::get().to(export_config)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/config/export")
.insert_header(("X-PIN", "123456"))
.insert_header(("Host", "10.0.0.42:8080"))
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(actix_web::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/json; charset=utf-8")
);
assert_eq!(
resp.headers()
.get(actix_web::http::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
Some("attachment; filename=\"notify.json\"")
);
let body = aw_test::read_body(resp).await;
let exported: ExportedConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(exported.name, "notify");
assert_eq!(exported.url, "http://10.0.0.42:8080");
assert_eq!(exported.pin, "123456");
assert_eq!(exported.api_key, "123456");
assert_eq!(exported.active, "notify");
}
#[actix_web::test]
async fn test_export_config_endpoint_with_custom_name_and_session() {
let config = web::Data::new(test_config());
let session_store = web::Data::new(crate::session::SessionStore::in_memory());
let token = session_store.create_session().await;
let cookie =
actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();
let app = aw_test::init_service(
App::new()
.app_data(config)
.app_data(session_store)
.route("/config/export", web::get().to(export_config)),
)
.await;
let req = aw_test::TestRequest::get()
.uri("/config/export?name=home&url=https://home.example.com")
.cookie(cookie)
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(actix_web::http::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
Some("attachment; filename=\"home.json\"")
);
let body = aw_test::read_body(resp).await;
let exported: ExportedConfig = serde_json::from_slice(&body).unwrap();
assert_eq!(exported.name, "home");
assert_eq!(exported.url, "https://home.example.com");
assert_eq!(exported.pin, "123456");
assert_eq!(exported.active, "home");
}
}