This commit is contained in:
Emilia Allison 2023-06-27 20:34:49 -04:00
commit 56a616fcd2
Signed by: emilia
GPG Key ID: 7A3F8997BFE894E0
18 changed files with 2926 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

2303
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

5
Cargo.toml Normal file
View File

@ -0,0 +1,5 @@
[workspace]
members = [
"server",
"front",
]

8
front/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "front"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

3
front/src/main.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

View File

@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

18
server/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
authors = ["Emilia Allison"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
dotenvy = "0.15"
env_logger = "0.10"
log = "0.4"
serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"
sqlx = { version = "0.6.2", features = ["runtime-actix-native-tls", "mysql", "time"]}
reqwest = { version = "0.11", features = ["json"] }
openssl = { version = "0.10", features = ["vendored"] }

26
server/src/joint_err.rs Normal file
View File

@ -0,0 +1,26 @@
use crate::sql_users::GetRefreshTokenByIdErr;
#[derive(Debug)]
pub enum JointErr {
SqlxError(sqlx::Error),
ReqwestError(reqwest::Error),
Empty,
}
impl From<sqlx::Error> for JointErr {
fn from(value: sqlx::Error) -> Self {
JointErr::SqlxError(value)
}
}
impl From<reqwest::Error> for JointErr {
fn from(value: reqwest::Error) -> Self {
JointErr::ReqwestError(value)
}
}
impl From<GetRefreshTokenByIdErr> for JointErr {
fn from(value: GetRefreshTokenByIdErr) -> Self {
match value {
GetRefreshTokenByIdErr::SqlxError(e) => e.into(),
GetRefreshTokenByIdErr::Empty => JointErr::Empty,
}
}
}

84
server/src/main.rs Normal file
View File

@ -0,0 +1,84 @@
mod spotify_auth;
mod spotify_get_top;
mod spotify_playlist;
mod spotify_types;
mod sql_users;
mod sql_tracks;
mod state;
mod joint_err;
mod refresh;
use spotify_auth::callback_manager;
use refresh::refresh_manager;
use actix_web::{App, HttpServer, HttpResponse, get, Responder, web};
use dotenvy::dotenv;
use std::env;
const BASE_URL: &'static str = "https://api.spotify.com/v1";
#[get("/")]
async fn root(data: web::Data<state::AppState>) -> impl Responder {
log::warn!("Access to root");
HttpResponse::Ok().body(format!("hi, pls go to: {}\n k thx", data.auth_url))
}
#[get("/failed")]
async fn failed() -> impl Responder {
HttpResponse::Ok().body("yeah sorry bud, something went wrong.")
}
#[get("/cool")]
async fn succeeded() -> impl Responder {
HttpResponse::Ok().body("whatever you were doing worked. bye")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::builder()
.target(env_logger::Target::Stdout)
.filter_level(log::LevelFilter::Warn)
.init();
dotenv().ok();
let port: u16 = std::env::var("PORT")
.expect("Cannot proceed without port")
.parse().expect("Port must be a number");
let address = std::env::var("ADDRESS").expect("Cannot proceed without a port to listen to");
let db_url = env::var("DATABASE_URL").expect("Cannot proceed without database url");
let pool = sqlx::mysql::MySqlPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Cannot proceed with failed database connection");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(state::AppState {
auth_url: construct_auth_url(),
pool: pool.clone(),
}))
.service(root)
.service(callback_manager)
.service(succeeded)
.service(failed)
.service(refresh_manager)
})
.bind((address, port))?
.run()
.await
}
fn construct_auth_url() -> String {
let redirect_url = env::var("REDIRECT_URL").expect("Cannot proceed without redirect url");
let client_id = env::var("CLIENT_ID").expect("Cannot proceed without client id");
format!("https://accounts.spotify.com/authorize?client_id={}&scope={}&redirect_uri={}&response_type={}",
client_id,
"user-top-read playlist-modify-public user-read-email",
redirect_url,
"code")
}

74
server/src/refresh.rs Normal file
View File

@ -0,0 +1,74 @@
use serde::Deserialize;
use actix_web::web::Redirect;
use actix_web::{HttpResponse, get, Responder, web};
use sqlx::MySqlPool;
use crate::spotify_get_top::get_top_tracks;
use crate::{sql_users, spotify_auth, sql_tracks, spotify_playlist};
use crate::joint_err::JointErr;
#[derive(Deserialize)]
pub struct RefreshData {
spotify_id: Option<String>,
force: Option<bool>,
}
#[get("/refresh")]
pub async fn refresh_manager(
data: web::Query<RefreshData>,
state: web::Data<crate::state::AppState>
) -> impl Responder
{
let pool = &state.pool;
if let Some(spotify_id) = &data.spotify_id {
let should_refresh = sql_users::get_refreshed_by_id(spotify_id, pool)
.await
.map(|time| sqlx::types::time::OffsetDateTime::now_utc() - time)
.map(|since| since.whole_hours().abs() >= 6)
.unwrap_or(false);
if should_refresh || data.force.unwrap_or(false) {
let e = refresh(spotify_id, pool).await;
log::error!("Refresh res was:\n{:?}", e);
HttpResponse::Ok().body("refreshing")
} else {
HttpResponse::TooManyRequests().body("it's been less than 6 hours. nah.")
}
} else {
HttpResponse::BadRequest().body("there's no ID here idiot")
}
}
async fn refresh(spotify_id: &str, pool: &MySqlPool) -> Result<(), JointErr> {
let token = spotify_auth::refresh_token_for_id(spotify_id, pool).await?;
let internal_id = sql_users::get_internal_id_by_id(spotify_id, pool).await?;
let top: Vec<_> = get_top_tracks(&token).await?.items
.iter()
.map(|track| track.uri.clone())
.collect();
for track in top {
log::info!("Trying to insert {}", track.0);
sqlx::query!("
INSERT INTO Tracks (UserId, Uri, DateAdded)
VALUES (?, ?, CURDATE())
ON DUPLICATE KEY UPDATE DateAdded = CURDATE()
", internal_id, track.0)
.execute(pool)
.await?;
}
// Reset playlist
set_playlist("6Zk0tvx4q6Zf5AZglvRLBT", pool).await?;
Ok(())
}
async fn set_playlist(id: &str, pool: &MySqlPool) -> Result<(), JointErr> {
let tracklist = sql_tracks::even_allocation(pool).await?;
let emilia_id = sql_users::get_emilia_spotify_id(pool).await?;
let token = spotify_auth::refresh_token_for_id(&emilia_id, pool).await?;
spotify_playlist::set_playlist(id, tracklist, &token, pool).await?;
Ok(())
}

148
server/src/spotify_auth.rs Normal file
View File

@ -0,0 +1,148 @@
use actix_web::web::Redirect;
use actix_web::{HttpResponse, get, Responder, web};
use serde::Deserialize;
use serde_json::json;
use sqlx::MySqlPool;
use sqlx::mysql::MySqlPoolOptions;
use std::{env, collections::HashMap};
use crate::spotify_get_top::get_top_tracks;
use crate::spotify_playlist::add_to_playlist;
use crate::spotify_types::*;
use crate::joint_err::JointErr;
use crate::sql_users::{add_user_dont_care, get_refresh_token_by_id, GetRefreshTokenByIdErr, AddUserSuccess};
const URL: &'static str = "https://accounts.spotify.com/api/token";
fn read_from_env() -> (String, String, String) {
let id = env::var("CLIENT_ID").expect("Cannot proceed without client id");
let secret = env::var("CLIENT_SECRET").expect("Cannot proceed without client secret");
let url = env::var("REDIRECT_URL").expect("Cannot proceed without redirect url");
return (id, secret, url)
}
#[derive(Deserialize)]
pub struct CallbackData {
code: String,
}
#[get("/callback")]
pub async fn callback_manager(data: web::Query<CallbackData>, state: web::Data<crate::state::AppState>) -> impl Responder {
log::warn!("Access to /callback");
let pool = &state.pool;
let tokens = get_auth_token(&data.code).await;
if let Ok(tokens) = tokens {
log::info!("{:?}", tokens.refresh_token);
let user_profile = get_user_email(&tokens.access_token).await;
if let Ok(up) = user_profile {
let q = add_user_dont_care(&up.email, &up.id, &tokens.refresh_token, &pool).await;
match q {
Ok(AddUserSuccess::New) => {
log::warn!("Added a new user: {}", up.id);
Redirect::to(format!("/refresh?spotify_id={}&force=true", up.id))
},
Ok(AddUserSuccess::Duplicate) => {
Redirect::to(format!("/refresh?spotify_id={}", up.id))
},
_ => {
log::error!("SQL Query Failed: couldn't add new user\n{:?}", q);
Redirect::to("/failed")
}
}
} else {
log::error!("We failed to get a user profile, the error was:\n{:?}", user_profile.unwrap_err());
Redirect::to("/failed")
}
} else {
log::error!("We failed to get tokens, the error was:\n{:?}", tokens.unwrap_err());
Redirect::to("/failed")
}
}
#[derive(Deserialize, Debug)]
#[non_exhaustive]
struct AuthorizationRequestResponse {
access_token: Token,
refresh_token: Token,
}
#[derive(Deserialize, Debug)]
pub struct Token(pub String);
async fn get_auth_token(code: &str) -> Result<AuthorizationRequestResponse, reqwest::Error> {
// Consider using env for REDIRECT_URI
//const REDIRECT_URI: &'static str = "https://ilia.moe/cool-stuff/cool-spotify-blend/callback";
const GRANT_TYPE: &'static str = "authorization_code";
let (id, secret, redirect_uri) = read_from_env();
let mut params = HashMap::new();
params.insert("grant_type", GRANT_TYPE.to_owned());
params.insert("code", code.to_owned());
params.insert("redirect_uri", redirect_uri.to_owned());
params.insert("client_id", id);
params.insert("client_secret", secret);
let client = reqwest::Client::new();
let res = client.post(URL.to_owned() )
.query(&params)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Content-Length", 0)
.send()
.await?
.json()
.await?;
Ok(res)
}
#[derive(Deserialize, Debug)]
struct RefreshTokenForIdRes {
access_token: Token
}
pub async fn refresh_token_for_id(spotify_id: &str, pool: &MySqlPool) -> Result<Token, JointErr> {
// Get the refresh token from the database:
let token = get_refresh_token_by_id(spotify_id, pool).await?;
let (id, secret, _) = read_from_env();
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("refresh_token", &token.0);
params.insert("client_id", &id);
params.insert("client_secret", &secret);
let client = reqwest::Client::new();
let res: RefreshTokenForIdRes = client.post(URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Content-Length", 0)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(res.access_token)
}
#[derive(Debug, Deserialize)]
struct UserProfile {
pub email: String,
pub id: String,
}
async fn get_user_email(token: &Token) -> Result<UserProfile, reqwest::Error> {
let client = reqwest::Client::new();
let res = client.get(crate::BASE_URL.to_owned() + "/me")
.header("Authorization", "Bearer ".to_owned() + &token.0)
.send()
.await?
.json()
.await?;
Ok(res)
}

View File

@ -0,0 +1,37 @@
use std::collections::HashMap;
use serde::Deserialize;
use crate::spotify_auth::Token;
use crate::spotify_types::*;
#[derive(Deserialize, Debug)]
pub struct TopSongsResponse {
pub href: String,
pub limit: u32, // Default is 20, we don't change this.
pub next: Option<String>,
pub offset: u32,
pub previous: Option<String>,
pub total: u32,
pub items: Vec<TrackObject>, // There should be 20 of these, not guaranteed
}
pub async fn get_top_tracks(token: &Token) -> Result<TopSongsResponse, reqwest::Error> {
let mut params = HashMap::new();
params.insert("time_range", "short_term");
let client = reqwest::Client::new();
let res: TopSongsResponse = client.get(crate::BASE_URL.to_owned() + "/me/top/tracks")
.query(&params)
.header("Authorization", "Bearer ".to_owned() + &token.0)
.send()
.await?
.json()
.await?;
Ok(res)
}

View File

@ -0,0 +1,50 @@
use std::collections::HashMap;
use sqlx::MySqlPool;
use crate::{spotify_types::*, spotify_auth::Token};
pub async fn add_to_playlist(id: &str, uris: Vec<Uri>, token: &Token) -> Result<(), reqwest::Error> {
let mut params = HashMap::new();
params.insert("position", "0"); // Insert at top, we could remove to append
let mut body: HashMap<String, Vec<String>> = HashMap::new();
let uris: Vec<String> = uris.iter()
.map(|u| &u.0)
.cloned()
.collect();
body.insert("uris".to_owned(), uris);
log::info!("Uri body: {:?}", body);
let client = reqwest::Client::new();
let res = client.post(crate::BASE_URL.to_owned() + "/playlists/" + id + "/tracks")
.query(&params)
.json(&body)
.header("Authorization", "Bearer ".to_owned() + &token.0)
.send()
.await?;
Ok(())
}
// WE CANNOT SET MORE THAN 100 AT ONCE!!!
pub async fn set_playlist(id: &str, uris: Vec<Uri>, token: &Token, pool: &MySqlPool)
-> Result<(), reqwest::Error> {
let mut body: HashMap<String, Vec<String>> = HashMap::new();
let uris: Vec<String> = uris.iter()
.map(|u| &u.0)
.cloned()
.collect();
body.insert("uris".to_owned(), uris);
let client = reqwest::Client::new();
let res = client.put(crate::BASE_URL.to_owned() + "/playlists/" + id + "/tracks")
.json(&body)
.header("Authorization", "Bearer ".to_owned() + &token.0)
.send()
.await?;
Ok(())
}

View File

@ -0,0 +1,37 @@
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TrackObject {
pub album: Option<AlbumObject>,
pub artists: Option<Vec<ArtistObject>>,
pub href: String,
pub id: String,
pub name: String,
pub uri: Uri,
}
#[derive(Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct AlbumObject {
pub album_type: String,
pub total_tracks: u32,
pub href: String,
pub id: String,
pub name: String,
pub release_date: String,
pub uri: Uri,
pub genres: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct ArtistObject {
pub href: String,
pub id: String,
pub name: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Uri(pub String);

33
server/src/sql_tracks.rs Normal file
View File

@ -0,0 +1,33 @@
use sqlx::{MySqlPool, mysql::MySqlQueryResult};
use crate::spotify_types::*;
#[allow(non_snake_case)] // Fixes warning for macro
pub async fn even_allocation(pool: &MySqlPool) -> Result<Vec<Uri>, sqlx::Error> {
let users: Vec<u32> = sqlx::query!("
SELECT UserId From Users
")
.fetch_all(pool)
.await?
.iter()
.map(|row| row.UserId)
.collect();
let mut uris: Vec<Uri> = Vec::new();
let num: u32 = (100 / users.len()) as u32;
for user in users {
let mut uri_list: Vec<Uri> = sqlx::query!("
SELECT Uri FROM Tracks
WHERE UserId = ?
ORDER BY RAND()
LIMIT ?
", user, num)
.fetch_all(pool)
.await?
.iter()
.map(|row| Uri(row.Uri.clone()))
.collect();
uris.append(&mut uri_list);
}
Ok(uris)
}

88
server/src/sql_users.rs Normal file
View File

@ -0,0 +1,88 @@
use sqlx::{MySqlPool, mysql::MySqlQueryResult};
use crate::spotify_auth::Token;
async fn add_user(email: &str, spotify_id: &str, refresh_token: &Token, pool: &MySqlPool)
-> Result<MySqlQueryResult, sqlx::Error> {
Ok(sqlx::query!(
"INSERT INTO spotify.Users (Email, SpotifyId, RefreshToken)
VALUES (?, ?, ?)",
email.to_owned(),
spotify_id.to_owned(),
refresh_token.0.to_owned()
).execute(pool).await?)
}
#[derive(Debug, PartialEq, Eq)]
pub enum AddUserSuccess {
New,
Duplicate
}
pub async fn add_user_dont_care(email: &str, spotify_id: &str, refresh_token: &Token, pool: &MySqlPool)
-> Result<AddUserSuccess, sqlx::Error> {
let q = add_user(email, spotify_id, refresh_token, pool).await;
match q {
Ok(_) => Ok(AddUserSuccess::New),
Err(sqlx::Error::Database(err)) => {
if err.code().map(|e| e.into_owned()) == Some("23000".to_owned()) {
Ok(AddUserSuccess::Duplicate)
} else {
Err(sqlx::Error::Database(err))
}
},
Err(_) => Err(q.unwrap_err())
}
}
pub enum GetRefreshTokenByIdErr {
SqlxError(sqlx::Error),
Empty,
}
impl From<sqlx::Error> for GetRefreshTokenByIdErr {
fn from(value: sqlx::Error) -> Self {
GetRefreshTokenByIdErr::SqlxError(value)
}
}
#[allow(non_snake_case)] // Fixes warning for macro
pub async fn get_refresh_token_by_id(spotify_id: &str, pool: &MySqlPool)
-> Result<Token, GetRefreshTokenByIdErr> {
let res = sqlx::query!("SELECT RefreshToken FROM Users WHERE SpotifyId LIKE ?",
spotify_id.to_owned())
.fetch_one(pool)
.await?;
match res.RefreshToken {
Some(token) => Ok(Token(token)),
None => Err(GetRefreshTokenByIdErr::Empty)
}
}
#[allow(non_snake_case)] // Fixes warning for macro
pub async fn get_refreshed_by_id(spotify_id: &str, pool: &MySqlPool)
-> Result<sqlx::types::time::OffsetDateTime, sqlx::Error> {
let res = sqlx::query!("SELECT LastRefreshed FROM Users WHERE SpotifyID LIKE ?",
spotify_id.to_owned())
.fetch_one(pool)
.await?;
Ok(res.LastRefreshed)
}
#[allow(non_snake_case)] // Fixes warning for macro
pub async fn get_internal_id_by_id(spotify_id: &str, pool: &MySqlPool)
-> Result<u32, sqlx::Error> {
let res = sqlx::query!("SELECT UserId FROM Users WHERE SpotifyID LIKE ?",
spotify_id.to_owned())
.fetch_one(pool)
.await?;
Ok(res.UserId)
}
#[allow(non_snake_case)] // Fixes warning for macro
pub async fn get_emilia_spotify_id(pool: &MySqlPool)
-> Result<String, sqlx::Error> {
let res = sqlx::query!("SELECT SpotifyId FROM Users WHERE IsEmilia = 1")
.fetch_one(pool)
.await?;
Ok(res.SpotifyId)
}

5
server/src/state.rs Normal file
View File

@ -0,0 +1,5 @@
#[non_exhaustive]
pub struct AppState {
pub auth_url: String,
pub pool: sqlx::MySqlPool,
}

3
src/main.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}