Files
tranquil-pds/src/api/notification/register_push.rs
T

151 lines
5.2 KiB
Rust

use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did};
use crate::api::ApiError;
use crate::state::AppState;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterPushInput {
pub service_did: String,
pub token: String,
pub platform: String,
pub app_id: String,
}
const VALID_PLATFORMS: &[&str] = &["ios", "android", "web"];
pub async fn register_push(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<RegisterPushInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = validate_did(&input.service_did) {
return ApiError::InvalidRequest(format!("Invalid serviceDid: {}", e)).into_response();
}
if input.token.is_empty() || input.token.len() > 4096 {
return ApiError::InvalidRequest("Invalid push token".to_string()).into_response();
}
if !VALID_PLATFORMS.contains(&input.platform.as_str()) {
return ApiError::InvalidRequest(format!(
"Invalid platform. Must be one of: {}",
VALID_PLATFORMS.join(", ")
))
.into_response();
}
if input.app_id.is_empty() || input.app_id.len() > 256 {
return ApiError::InvalidRequest("Invalid appId".to_string()).into_response();
}
let appview_url = match std::env::var("APPVIEW_URL") {
Ok(url) => url,
Err(_) => {
return ApiError::UpstreamUnavailable("No upstream AppView configured".to_string())
.into_response();
}
};
if let Err(e) = is_ssrf_safe(&appview_url) {
error!("SSRF check failed for appview URL: {}", e);
return ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
.into_response();
}
let key_row = match sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys k JOIN users u ON k.user_id = u.id WHERE u.did = $1",
auth_user.did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
Ok(None) => {
error!(did = %auth_user.did, "No signing key found for user");
return ApiError::InternalError.into_response();
}
Err(e) => {
error!(error = ?e, "Database error fetching signing key");
return ApiError::DatabaseError.into_response();
}
};
let decrypted_key =
match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) {
Ok(k) => k,
Err(e) => {
error!(error = ?e, "Failed to decrypt signing key");
return ApiError::InternalError.into_response();
}
};
let service_token = match crate::auth::create_service_token(
&auth_user.did,
&input.service_did,
"app.bsky.notification.registerPush",
&decrypted_key,
) {
Ok(t) => t,
Err(e) => {
error!(error = ?e, "Failed to create service token");
return ApiError::InternalError.into_response();
}
};
let target_url = format!("{}/xrpc/app.bsky.notification.registerPush", appview_url);
info!(
target = %target_url,
service_did = %input.service_did,
platform = %input.platform,
"Proxying registerPush request"
);
let client = proxy_client();
let request_body = json!({
"serviceDid": input.service_did,
"token": input.token,
"platform": input.platform,
"appId": input.app_id
});
match client
.post(&target_url)
.header("Authorization", format!("Bearer {}", service_token))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
.await
{
Ok(resp) => {
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if status.is_success() {
StatusCode::OK.into_response()
} else {
let body = resp.bytes().await.unwrap_or_default();
error!(
status = %status,
"registerPush upstream error"
);
ApiError::from_upstream_response(status.as_u16(), &body).into_response()
}
}
Err(e) => {
error!(error = ?e, "Error proxying registerPush");
if e.is_timeout() {
ApiError::UpstreamTimeout.into_response()
} else if e.is_connect() {
ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string())
.into_response()
} else {
ApiError::UpstreamFailure.into_response()
}
}
}
}