Add OAuth2 web interface for automated Spotify authentication
- Add /auth endpoint with user-friendly web form - Add OAuth callback handler that automatically exchanges codes for tokens - Update redirect URI to spotify.tougie.live subdomain - Add success/error pages for authentication flow - Switch to rustls-only for better cross-platform compatibility - Update server endpoints to show spotify.tougie.live URLs - Remove manual code entry requirement
This commit is contained in:
parent
bcfa2ba1c2
commit
e666bbe9ff
|
@ -5,7 +5,7 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
[spotify]
|
[spotify]
|
||||||
client_id = "your_spotify_client_id_here"
|
client_id = "your_spotify_client_id_here"
|
||||||
client_secret = "your_spotify_client_secret_here"
|
client_secret = "your_spotify_client_secret_here"
|
||||||
redirect_uri = "http://localhost:8888/callback"
|
redirect_uri = "https://spotify.tougie.live"
|
||||||
scopes = ["user-read-currently-playing", "user-read-playback-state"]
|
scopes = ["user-read-currently-playing", "user-read-playback-state"]
|
||||||
|
|
||||||
# Output format: "json", "plain", or "formatted"
|
# Output format: "json", "plain", or "formatted"
|
||||||
|
|
|
@ -30,6 +30,7 @@ impl Default for AppConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ConfigManager {
|
pub struct ConfigManager {
|
||||||
config_dir: PathBuf,
|
config_dir: PathBuf,
|
||||||
config_file: PathBuf,
|
config_file: PathBuf,
|
||||||
|
|
14
src/main.rs
14
src/main.rs
|
@ -390,14 +390,14 @@ async fn handle_server(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
port: u16,
|
port: u16,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let token_info = config_manager.load_token()?
|
// Don't require authentication for server mode - it will handle OAuth flow
|
||||||
.ok_or_else(|| SpotifyError::AuthError {
|
|
||||||
message: "Not authenticated. Run 'spotify-tracker auth' first.".to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let client = SpotifyClient::new(config.spotify.clone());
|
let client = SpotifyClient::new(config.spotify.clone());
|
||||||
client.set_token(token_info).await;
|
|
||||||
|
|
||||||
let server = SpotifyServer::new(client, port);
|
// If we have a token, set it
|
||||||
|
if let Ok(Some(token_info)) = config_manager.load_token() {
|
||||||
|
client.set_token(token_info).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = SpotifyServer::new(client, config_manager.clone(), port);
|
||||||
server.start().await
|
server.start().await
|
||||||
}
|
}
|
|
@ -14,7 +14,7 @@ impl Default for SpotifyConfig {
|
||||||
Self {
|
Self {
|
||||||
client_id: String::new(),
|
client_id: String::new(),
|
||||||
client_secret: String::new(),
|
client_secret: String::new(),
|
||||||
redirect_uri: "http://localhost:8888/callback".to_string(),
|
redirect_uri: "https://spotify.tougie.live".to_string(),
|
||||||
scopes: vec![
|
scopes: vec![
|
||||||
"user-read-currently-playing".to_string(),
|
"user-read-currently-playing".to_string(),
|
||||||
"user-read-playback-state".to_string(),
|
"user-read-playback-state".to_string(),
|
||||||
|
|
177
src/server.rs
177
src/server.rs
|
@ -1,18 +1,21 @@
|
||||||
use crate::{Result, SpotifyClient, SpotifyError};
|
use crate::{ConfigManager, Result, SpotifyClient, SpotifyError};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use warp::Filter;
|
use warp::Filter;
|
||||||
|
|
||||||
pub struct SpotifyServer {
|
pub struct SpotifyServer {
|
||||||
client: Arc<SpotifyClient>,
|
client: Arc<SpotifyClient>,
|
||||||
|
config_manager: Arc<ConfigManager>,
|
||||||
port: u16,
|
port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpotifyServer {
|
impl SpotifyServer {
|
||||||
pub fn new(client: SpotifyClient, port: u16) -> Self {
|
pub fn new(client: SpotifyClient, config_manager: ConfigManager, port: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Arc::new(client),
|
client: Arc::new(client),
|
||||||
|
config_manager: Arc::new(config_manager),
|
||||||
port,
|
port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,6 +24,7 @@ impl SpotifyServer {
|
||||||
log::info!("Starting Spotify Tracker server on port {}", self.port);
|
log::info!("Starting Spotify Tracker server on port {}", self.port);
|
||||||
|
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
|
let config_manager = self.config_manager.clone();
|
||||||
|
|
||||||
// Current track endpoint - mimics your existing API
|
// Current track endpoint - mimics your existing API
|
||||||
let current_track = {
|
let current_track = {
|
||||||
|
@ -68,6 +72,31 @@ impl SpotifyServer {
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// OAuth authorization start endpoint
|
||||||
|
let auth_start = {
|
||||||
|
let client = client.clone();
|
||||||
|
warp::path("auth")
|
||||||
|
.and(warp::get())
|
||||||
|
.map(move || client.clone())
|
||||||
|
.map(|client: Arc<SpotifyClient>| {
|
||||||
|
let auth_url = client.get_authorization_url(Some("spotify-tracker"));
|
||||||
|
warp::reply::html(get_auth_page(&auth_url))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// OAuth callback endpoint
|
||||||
|
let oauth_callback = {
|
||||||
|
let client = client.clone();
|
||||||
|
let config_manager = config_manager.clone();
|
||||||
|
warp::path::end()
|
||||||
|
.and(warp::get())
|
||||||
|
.and(warp::query::<HashMap<String, String>>())
|
||||||
|
.map(move |params: HashMap<String, String>| (client.clone(), config_manager.clone(), params))
|
||||||
|
.and_then(|(client, config_manager, params): (Arc<SpotifyClient>, Arc<ConfigManager>, HashMap<String, String>)| async move {
|
||||||
|
handle_oauth_callback(client, config_manager, params).await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
let health = warp::path("health")
|
let health = warp::path("health")
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
|
@ -87,15 +116,18 @@ impl SpotifyServer {
|
||||||
|
|
||||||
let routes = current_track
|
let routes = current_track
|
||||||
.or(phantombot)
|
.or(phantombot)
|
||||||
|
.or(auth_start)
|
||||||
|
.or(oauth_callback)
|
||||||
.or(health)
|
.or(health)
|
||||||
.with(cors)
|
.with(cors)
|
||||||
.with(warp::log("spotify_tracker"));
|
.with(warp::log("spotify_tracker"));
|
||||||
|
|
||||||
println!("🎵 Spotify Tracker Server Running!");
|
println!("🎵 Spotify Tracker Server Running!");
|
||||||
println!("================================");
|
println!("================================");
|
||||||
println!("Current track (JSON): http://localhost:{}/current", self.port);
|
println!("OAuth Setup: https://spotify.tougie.live/auth", );
|
||||||
println!("PhantomBot format: http://localhost:{}/phantombot", self.port);
|
println!("Current track (JSON): https://spotify.tougie.live/current");
|
||||||
println!("Health check: http://localhost:{}/health", self.port);
|
println!("PhantomBot format: https://spotify.tougie.live/phantombot");
|
||||||
|
println!("Health check: https://spotify.tougie.live/health");
|
||||||
println!();
|
println!();
|
||||||
println!("Press Ctrl+C to stop");
|
println!("Press Ctrl+C to stop");
|
||||||
|
|
||||||
|
@ -141,6 +173,141 @@ async fn get_current_track_text(client: Arc<SpotifyClient>) -> Result<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_oauth_callback(
|
||||||
|
client: Arc<SpotifyClient>,
|
||||||
|
config_manager: Arc<ConfigManager>,
|
||||||
|
params: HashMap<String, String>,
|
||||||
|
) -> std::result::Result<impl warp::Reply, std::convert::Infallible> {
|
||||||
|
if let Some(code) = params.get("code") {
|
||||||
|
// Exchange code for token
|
||||||
|
match client.exchange_code(code).await {
|
||||||
|
Ok(token_info) => {
|
||||||
|
// Save token
|
||||||
|
match config_manager.save_token(&token_info) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("OAuth authentication successful");
|
||||||
|
Ok(warp::reply::html(get_success_page()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to save token: {}", e);
|
||||||
|
Ok(warp::reply::html(get_error_page(&format!("Failed to save token: {}", e))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("OAuth token exchange failed: {}", e);
|
||||||
|
Ok(warp::reply::html(get_error_page(&format!("Authentication failed: {}", e))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(error) = params.get("error") {
|
||||||
|
let error_description = params.get("error_description").unwrap_or(error);
|
||||||
|
log::error!("OAuth error: {}", error_description);
|
||||||
|
Ok(warp::reply::html(get_error_page(&format!("Authentication error: {}", error_description))))
|
||||||
|
} else {
|
||||||
|
log::warn!("OAuth callback received with no code or error");
|
||||||
|
Ok(warp::reply::html(get_error_page("Invalid callback parameters")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_auth_page(auth_url: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spotify Tracker - Authentication</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }}
|
||||||
|
.container {{ background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
|
||||||
|
.spotify-btn {{ background: #1DB954; color: white; padding: 15px 30px; border: none; border-radius: 50px; font-size: 16px; text-decoration: none; display: inline-block; margin: 20px 0; }}
|
||||||
|
.spotify-btn:hover {{ background: #1ed760; }}
|
||||||
|
h1 {{ color: #333; }}
|
||||||
|
p {{ color: #666; line-height: 1.6; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🎵 Spotify Tracker Authentication</h1>
|
||||||
|
<p>To use Spotify Tracker, you need to authorize access to your Spotify account.</p>
|
||||||
|
<p>Click the button below to authorize with Spotify. You'll be redirected back here after authentication.</p>
|
||||||
|
<a href="{}" class="spotify-btn">Authorize with Spotify</a>
|
||||||
|
<p><small>This will redirect you to Spotify's authorization page, then back to this site.</small></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
auth_url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_success_page() -> String {
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spotify Tracker - Success</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }
|
||||||
|
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; }
|
||||||
|
.success { color: #1DB954; font-size: 48px; margin-bottom: 20px; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
p { color: #666; line-height: 1.6; }
|
||||||
|
.api-endpoints { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; text-align: left; }
|
||||||
|
.endpoint { font-family: monospace; background: #e9ecef; padding: 5px 10px; border-radius: 3px; margin: 5px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success">✅</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>Your Spotify account has been successfully linked to Spotify Tracker.</p>
|
||||||
|
|
||||||
|
<div class="api-endpoints">
|
||||||
|
<h3>Available Endpoints:</h3>
|
||||||
|
<div class="endpoint">GET https://spotify.tougie.live/current - Current track (JSON)</div>
|
||||||
|
<div class="endpoint">GET https://spotify.tougie.live/phantombot - Current track (text)</div>
|
||||||
|
<div class="endpoint">GET https://spotify.tougie.live/health - Health check</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>You can now use the Spotify Tracker API to get your current playing track!</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_error_page(error: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Spotify Tracker - Error</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }}
|
||||||
|
.container {{ background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; }}
|
||||||
|
.error {{ color: #dc3545; font-size: 48px; margin-bottom: 20px; }}
|
||||||
|
h1 {{ color: #333; }}
|
||||||
|
p {{ color: #666; line-height: 1.6; }}
|
||||||
|
.retry-btn {{ background: #1DB954; color: white; padding: 15px 30px; border: none; border-radius: 50px; font-size: 16px; text-decoration: none; display: inline-block; margin: 20px 0; }}
|
||||||
|
.retry-btn:hover {{ background: #1ed760; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="error">❌</div>
|
||||||
|
<h1>Authentication Failed</h1>
|
||||||
|
<p>There was an error during authentication:</p>
|
||||||
|
<p><strong>{}</strong></p>
|
||||||
|
<a href="/auth" class="retry-btn">Try Again</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
Loading…
Reference in a new issue