use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::state::{AppState, PendingOAuth};
const OAUTH_STATE_TTL: Duration = Duration::from_secs(600);
pub async fn link_google_drive(
State(state): State<Arc<AppState>>,
Query(params): Query<LinkParams>,
) -> Response {
let config = match load_connector_config(&state) {
Ok(c) => c,
Err(resp) => return resp,
};
let connector =
match tuitbot_core::source::connector::google_drive::GoogleDriveConnector::new(&config) {
Ok(c) => c,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
let existing =
tuitbot_core::storage::watchtower::get_connections_by_type(&state.db, "google_drive").await;
if let Ok(ref conns) = existing {
if !conns.is_empty() && !params.force.unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(json!({
"error": "an active Google Drive connection already exists",
"hint": "disconnect first or pass ?force=true"
})),
)
.into_response();
}
}
let code_verifier = hex::encode(random_bytes(64));
let hash = Sha256::digest(code_verifier.as_bytes());
let code_challenge = base64url_encode(&hash);
let oauth_state = hex::encode(random_bytes(32));
let auth_url = match tuitbot_core::source::connector::RemoteConnector::authorization_url(
&connector,
&oauth_state,
&code_challenge,
) {
Ok(url) => url,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
{
let mut pending = state.pending_oauth.lock().await;
pending.retain(|_, v| v.created_at.elapsed() < OAUTH_STATE_TTL);
pending.insert(
oauth_state.clone(),
PendingOAuth {
code_verifier,
created_at: std::time::Instant::now(),
account_id: String::new(),
client_id: String::new(),
},
);
}
(
StatusCode::OK,
Json(json!({
"authorization_url": auth_url,
"state": oauth_state
})),
)
.into_response()
}
#[derive(serde::Deserialize)]
pub struct LinkParams {
force: Option<bool>,
}
pub async fn callback_google_drive(
State(state): State<Arc<AppState>>,
Query(params): Query<CallbackParams>,
) -> Response {
let code = match params.code {
Some(c) if !c.is_empty() => c,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "missing code parameter"})),
)
.into_response();
}
};
let oauth_state = match params.state {
Some(s) if !s.is_empty() => s,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "missing state parameter"})),
)
.into_response();
}
};
let code_verifier = {
let mut pending = state.pending_oauth.lock().await;
match pending.remove(&oauth_state) {
Some(p) if p.created_at.elapsed() < OAUTH_STATE_TTL => p.code_verifier,
Some(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "state expired"})),
)
.into_response();
}
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid or expired state"})),
)
.into_response();
}
}
};
let config = match load_connector_config(&state) {
Ok(c) => c,
Err(resp) => return resp,
};
let connector =
match tuitbot_core::source::connector::google_drive::GoogleDriveConnector::new(&config) {
Ok(c) => c,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
let tokens = match tuitbot_core::source::connector::RemoteConnector::exchange_code(
&connector,
&code,
&code_verifier,
)
.await
{
Ok(t) => t,
Err(e) => {
tracing::error!(error = %e, "OAuth token exchange failed");
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("token exchange failed: {e}")})),
)
.into_response();
}
};
let user_info = match tuitbot_core::source::connector::RemoteConnector::user_info(
&connector,
&tokens.access_token,
)
.await
{
Ok(info) => info,
Err(e) => {
tracing::warn!(error = %e, "Failed to fetch user info, proceeding without");
tuitbot_core::source::connector::UserInfo {
email: "unknown".to_string(),
display_name: None,
}
}
};
let key = match tuitbot_core::source::connector::crypto::ensure_connector_key(&state.data_dir) {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, "Failed to load connector key");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "encryption key error"})),
)
.into_response();
}
};
let encrypted = match tuitbot_core::source::connector::google_drive::encrypt_refresh_token(
&tokens.refresh_token,
&key,
) {
Ok(enc) => enc,
Err(e) => {
tracing::error!(error = %e, "Failed to encrypt refresh token");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "encryption failed"})),
)
.into_response();
}
};
let conn_id = match tuitbot_core::storage::watchtower::insert_connection(
&state.db,
"google_drive",
Some(&user_info.email),
user_info.display_name.as_deref(),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::error!(error = %e, "Failed to insert connection");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "database error"})),
)
.into_response();
}
};
if let Err(e) = tuitbot_core::storage::watchtower::store_encrypted_credentials(
&state.db, conn_id, &encrypted,
)
.await
{
tracing::error!(error = %e, "Failed to store encrypted credentials");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "credential storage error"})),
)
.into_response();
}
let metadata = json!({
"scope": tokens.scope,
"linked_at": chrono::Utc::now().to_rfc3339(),
});
if let Err(e) = tuitbot_core::storage::watchtower::update_connection_metadata(
&state.db,
conn_id,
&metadata.to_string(),
)
.await
{
tracing::warn!(error = %e, "Failed to update connection metadata");
}
Html(format!(
r#"<!DOCTYPE html>
<html><head><title>Tuitbot - Connected</title></head>
<body style="font-family:system-ui;text-align:center;padding:60px">
<h2>Google Drive Connected</h2>
<p>Account: {email}</p>
<p>You can close this tab and return to the dashboard.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ type: "connector_linked", connector: "google_drive", id: {conn_id} }}, "*");
}}
</script>
</body></html>"#,
email = html_escape(&user_info.email),
))
.into_response()
}
#[derive(serde::Deserialize)]
pub struct CallbackParams {
code: Option<String>,
state: Option<String>,
}
pub async fn status_google_drive(State(state): State<Arc<AppState>>) -> Response {
match tuitbot_core::storage::watchtower::get_connections_by_type(&state.db, "google_drive")
.await
{
Ok(conns) => (StatusCode::OK, Json(json!({ "connections": conns }))).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
pub async fn disconnect_google_drive(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Response {
let conn = match tuitbot_core::storage::watchtower::get_connection(&state.db, id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "connection not found"})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
if conn.connector_type != "google_drive" {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "not a Google Drive connection"})),
)
.into_response();
}
let encrypted = tuitbot_core::storage::watchtower::read_encrypted_credentials(&state.db, id)
.await
.ok()
.flatten();
if let Some(ref enc) = encrypted {
if let Ok(key) =
tuitbot_core::source::connector::crypto::ensure_connector_key(&state.data_dir)
{
if let Ok(config) = load_connector_config(&state) {
if let Ok(connector) =
tuitbot_core::source::connector::google_drive::GoogleDriveConnector::new(
&config,
)
{
if let Err(e) = tuitbot_core::source::connector::RemoteConnector::revoke(
&connector, enc, &key,
)
.await
{
tracing::warn!(
connection_id = id,
error = %e,
"Token revocation failed during disconnect"
);
}
}
}
}
}
if let Err(e) = tuitbot_core::storage::watchtower::delete_connection(&state.db, id).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
(
StatusCode::OK,
Json(json!({ "disconnected": true, "id": id })),
)
.into_response()
}
#[allow(clippy::result_large_err)]
fn load_connector_config(
state: &AppState,
) -> Result<tuitbot_core::config::GoogleDriveConnectorConfig, Response> {
Ok(state.connector_config.google_drive.clone())
}
fn random_bytes(n: usize) -> Vec<u8> {
(0..n).map(|_| rand::random::<u8>()).collect()
}
fn base64url_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_escape_basic() {
assert_eq!(html_escape("hello"), "hello");
}
#[test]
fn html_escape_ampersand() {
assert_eq!(html_escape("a&b"), "a&b");
}
#[test]
fn html_escape_angle_brackets() {
assert_eq!(html_escape("<script>"), "<script>");
}
#[test]
fn html_escape_quotes() {
assert_eq!(html_escape(r#"say "hi""#), "say "hi"");
}
#[test]
fn html_escape_all_chars() {
assert_eq!(
html_escape(r#"<a href="x">a&b</a>"#),
"<a href="x">a&b</a>"
);
}
#[test]
fn html_escape_empty() {
assert_eq!(html_escape(""), "");
}
#[test]
fn base64url_encode_basic() {
let data = b"test data for encoding";
let encoded = base64url_encode(data);
assert!(!encoded.is_empty());
assert!(!encoded.contains('='), "no padding in URL-safe base64");
assert!(!encoded.contains('+'), "no + in URL-safe base64");
assert!(!encoded.contains('/'), "no / in URL-safe base64");
}
#[test]
fn base64url_encode_empty() {
let encoded = base64url_encode(b"");
assert!(encoded.is_empty());
}
#[test]
fn random_bytes_correct_length() {
let bytes = random_bytes(32);
assert_eq!(bytes.len(), 32);
}
#[test]
fn random_bytes_zero_length() {
let bytes = random_bytes(0);
assert!(bytes.is_empty());
}
#[test]
fn random_bytes_unique() {
let a = random_bytes(32);
let b = random_bytes(32);
assert_ne!(a, b);
}
#[test]
fn oauth_state_ttl_is_10_minutes() {
assert_eq!(OAUTH_STATE_TTL, Duration::from_secs(600));
}
#[test]
fn html_escape_only_special_chars() {
assert_eq!(html_escape("&<>\""), "&<>"");
}
#[test]
fn html_escape_mixed_with_normal() {
assert_eq!(
html_escape("user@example.com & \"friends\""),
"user@example.com & "friends""
);
}
#[test]
fn html_escape_unicode_preserved() {
assert_eq!(html_escape("hello world"), "hello world");
}
#[test]
fn html_escape_no_single_quote_escaping() {
assert_eq!(html_escape("it's"), "it's");
}
#[test]
fn html_escape_nested_tags() {
assert_eq!(
html_escape("<div><span>text</span></div>"),
"<div><span>text</span></div>"
);
}
#[test]
fn base64url_encode_known_value() {
let data = [0u8; 32];
let encoded = base64url_encode(&data);
assert_eq!(encoded.len(), 43); assert!(!encoded.contains('='));
}
#[test]
fn base64url_encode_single_byte() {
let encoded = base64url_encode(&[0xFF]);
assert!(!encoded.is_empty());
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
}
#[test]
fn base64url_encode_deterministic() {
let data = b"PKCE code challenge test";
let a = base64url_encode(data);
let b = base64url_encode(data);
assert_eq!(a, b);
}
#[test]
fn random_bytes_large() {
let bytes = random_bytes(256);
assert_eq!(bytes.len(), 256);
}
#[test]
fn random_bytes_one() {
let bytes = random_bytes(1);
assert_eq!(bytes.len(), 1);
}
#[test]
fn pkce_code_challenge_flow() {
let code_verifier = hex::encode(random_bytes(64));
assert_eq!(code_verifier.len(), 128);
let hash = sha2::Sha256::digest(code_verifier.as_bytes());
let code_challenge = base64url_encode(&hash);
assert_eq!(code_challenge.len(), 43);
assert!(!code_challenge.contains('='));
assert!(!code_challenge.contains('+'));
}
#[test]
fn oauth_state_generation() {
let state = hex::encode(random_bytes(32));
assert_eq!(state.len(), 64); }
#[test]
fn callback_params_deserialize_full() {
let json = r#"{"code": "auth_code_123", "state": "state_abc"}"#;
let params: CallbackParams = serde_json::from_str(json).unwrap();
assert_eq!(params.code.as_deref(), Some("auth_code_123"));
assert_eq!(params.state.as_deref(), Some("state_abc"));
}
#[test]
fn callback_params_deserialize_empty() {
let json = r#"{}"#;
let params: CallbackParams = serde_json::from_str(json).unwrap();
assert!(params.code.is_none());
assert!(params.state.is_none());
}
#[test]
fn link_params_deserialize_no_force() {
let json = r#"{}"#;
let params: LinkParams = serde_json::from_str(json).unwrap();
assert!(params.force.is_none());
}
#[test]
fn link_params_deserialize_force_true() {
let json = r#"{"force": true}"#;
let params: LinkParams = serde_json::from_str(json).unwrap();
assert_eq!(params.force, Some(true));
}
#[test]
fn link_params_deserialize_force_false() {
let json = r#"{"force": false}"#;
let params: LinkParams = serde_json::from_str(json).unwrap();
assert_eq!(params.force, Some(false));
}
#[test]
fn pkce_verifier_length_128_hex() {
let verifier = hex::encode(random_bytes(64));
assert_eq!(verifier.len(), 128);
assert!(verifier.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn pkce_challenge_from_verifier() {
let verifier = hex::encode(random_bytes(64));
let hash = sha2::Sha256::digest(verifier.as_bytes());
let challenge = base64url_encode(&hash);
assert_eq!(challenge.len(), 43);
assert!(!challenge.contains('='));
}
#[test]
fn pkce_different_verifiers_different_challenges() {
let v1 = hex::encode(random_bytes(64));
let v2 = hex::encode(random_bytes(64));
let h1 = sha2::Sha256::digest(v1.as_bytes());
let h2 = sha2::Sha256::digest(v2.as_bytes());
let c1 = base64url_encode(&h1);
let c2 = base64url_encode(&h2);
assert_ne!(
c1, c2,
"different verifiers should produce different challenges"
);
}
#[test]
fn oauth_state_is_64_hex_chars() {
let state = hex::encode(random_bytes(32));
assert_eq!(state.len(), 64);
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn html_escape_long_string() {
let input = "<script>alert('xss')</script>".repeat(100);
let escaped = html_escape(&input);
assert!(!escaped.contains('<'));
assert!(!escaped.contains('>'));
}
#[test]
fn html_escape_newlines_preserved() {
assert_eq!(html_escape("line1\nline2"), "line1\nline2");
}
#[test]
fn base64url_encode_all_zeros() {
let data = vec![0u8; 64];
let encoded = base64url_encode(&data);
assert!(!encoded.is_empty());
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
assert!(!encoded.contains('='));
}
#[test]
fn base64url_encode_all_ones() {
let data = vec![0xFFu8; 32];
let encoded = base64url_encode(&data);
assert_eq!(encoded.len(), 43); }
#[test]
fn callback_params_with_only_code() {
let json = r#"{"code": "abc123"}"#;
let params: CallbackParams = serde_json::from_str(json).unwrap();
assert_eq!(params.code.as_deref(), Some("abc123"));
assert!(params.state.is_none());
}
#[test]
fn callback_params_with_only_state() {
let json = r#"{"state": "xyz789"}"#;
let params: CallbackParams = serde_json::from_str(json).unwrap();
assert!(params.code.is_none());
assert_eq!(params.state.as_deref(), Some("xyz789"));
}
#[test]
fn callback_params_empty_strings() {
let json = r#"{"code": "", "state": ""}"#;
let params: CallbackParams = serde_json::from_str(json).unwrap();
assert_eq!(params.code.as_deref(), Some(""));
assert_eq!(params.state.as_deref(), Some(""));
}
#[test]
fn random_bytes_not_all_zeros() {
let bytes = random_bytes(64);
assert!(bytes.iter().any(|&b| b != 0));
}
#[test]
fn random_bytes_64_for_verifier() {
let bytes = random_bytes(64);
assert_eq!(bytes.len(), 64);
let hex = hex::encode(&bytes);
assert_eq!(hex.len(), 128);
}
}