mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 08:57:54 +00:00
API tokens migration, queries, and handlers (list, create)
This commit is contained in:
1 parent
23e6f552e7
commit
653c282f07
11 files changed
+457
No files matched your search
@@ -7,6 +7,10 @@ CREATE TABLE api_tokens (
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Idempotency token from client
|
||||
-- This is so the frontend client doesn't submit duplicate jobs.
|
||||
uuid_idempotency_token VARCHAR(36) NOT NULL,
|
||||
|
||||
-- This is the secret API token value
|
||||
api_token VARCHAR(32) NOT NULL,
|
||||
|
||||
@@ -24,7 +28,9 @@ CREATE TABLE api_tokens (
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (api_token),
|
||||
UNIQUE KEY (uuid_idempotency_token),
|
||||
KEY fk_user_token (user_token),
|
||||
KEY index_created_at (created_at),
|
||||
KEY index_deleted_at (deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,85 @@
|
||||
use container_common::anyhow_result::AnyhowResult;
|
||||
use sqlx::MySqlPool;
|
||||
use crate::tokens::Tokens;
|
||||
use sqlx;
|
||||
use anyhow::anyhow;
|
||||
use crate::api_tokens::list_available_api_tokens_for_user::list_available_api_tokens_for_user;
|
||||
|
||||
/// Create a new API token for the user.
|
||||
/// If the user has more than five tokens, delete the least recent.
|
||||
pub async fn create_api_token_for_user(
|
||||
user_token: &str,
|
||||
uuid_idempotency_token: &str,
|
||||
mysql_pool: &MySqlPool
|
||||
) -> AnyhowResult<String> {
|
||||
|
||||
// TODO: Get last 5 API tokens for the user.
|
||||
// TODO: Database transaction.
|
||||
|
||||
let api_token = Tokens::new_api_token()?;
|
||||
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO api_tokens
|
||||
SET
|
||||
api_token = ?,
|
||||
user_token = ?,
|
||||
uuid_idempotency_token = ?
|
||||
"#,
|
||||
api_token,
|
||||
user_token,
|
||||
uuid_idempotency_token
|
||||
);
|
||||
|
||||
let query_result = query.execute(mysql_pool).await;
|
||||
|
||||
let _record_id = match query_result {
|
||||
Ok(res) => res.last_insert_id(),
|
||||
Err(err) => return Err(anyhow!("Error creating API token: {:?}", err)),
|
||||
};
|
||||
|
||||
// Delete older tokens.
|
||||
let valid_api_tokens= list_available_api_tokens_for_user(user_token, mysql_pool).await?;
|
||||
|
||||
let valid_api_tokens = valid_api_tokens.into_iter()
|
||||
.map(|r| r.api_token)
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
// TODO: THis is a massive hack since SQLx doesn't support binding Vec<T>
|
||||
// This array's elements will be replaced with any valid tokens.
|
||||
let mut api_tokens = [
|
||||
api_token.as_str(),
|
||||
api_token.as_str(),
|
||||
api_token.as_str(),
|
||||
api_token.as_str(),
|
||||
api_token.as_str(),
|
||||
api_token.as_str(), // One extra copy of the latest token.
|
||||
];
|
||||
|
||||
for i in 0..5 {
|
||||
if let Some(token) = valid_api_tokens.get(i).map(|s| s.as_str()) {
|
||||
api_tokens[i] = token;
|
||||
}
|
||||
}
|
||||
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET
|
||||
deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE
|
||||
user_token = ?
|
||||
AND api_token NOT IN (?, ?, ?, ?, ?)
|
||||
"#,
|
||||
user_token,
|
||||
&api_tokens[0],
|
||||
&api_tokens[1],
|
||||
&api_tokens[2],
|
||||
&api_tokens[3],
|
||||
&api_tokens[4],
|
||||
);
|
||||
|
||||
let _query_result = query.execute(mysql_pool).await?;
|
||||
|
||||
Ok(api_token)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use anyhow::anyhow;
|
||||
use chrono::{Utc, DateTime};
|
||||
use container_common::anyhow_result::AnyhowResult;
|
||||
use crate::column_types::job_status::JobStatus;
|
||||
use crate::column_types::record_visibility::RecordVisibility;
|
||||
use crate::helpers::boolean_converters::i8_to_bool;
|
||||
use crate::tts::tts_inference_jobs::_keys::TtsInferenceJobId;
|
||||
use log::{warn, info};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
/// table: api_tokens
|
||||
#[derive(Debug)]
|
||||
pub struct AvailableApiToken {
|
||||
pub api_token: String,
|
||||
pub maybe_short_description: Option<String>,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub updated_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Query available (non-deleted) API tokens for a user.
|
||||
/// A user can only have five active tokens at a time, so we only return the five most recent.
|
||||
pub async fn list_available_api_tokens_for_user(
|
||||
user_token: &str,
|
||||
pool: &MySqlPool,
|
||||
) -> AnyhowResult<Vec<AvailableApiToken>> {
|
||||
|
||||
let records : Vec<AvailableApiTokenInternal> = sqlx::query_as!(
|
||||
AvailableApiTokenInternal,
|
||||
r#"
|
||||
SELECT
|
||||
api_token,
|
||||
maybe_short_description,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM api_tokens
|
||||
WHERE
|
||||
user_token = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
"#,
|
||||
user_token,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut records = records.into_iter()
|
||||
.map(|record : AvailableApiTokenInternal | {
|
||||
AvailableApiToken {
|
||||
api_token: record.api_token,
|
||||
maybe_short_description: record.maybe_short_description,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<AvailableApiToken>>();
|
||||
|
||||
// Queried in DESC order, but sort returned results ordered ASC.
|
||||
records.sort_by_key(|r| r.created_at);
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AvailableApiTokenInternal {
|
||||
pub api_token: String,
|
||||
pub maybe_short_description: Option<String>,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub updated_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_api_token;
|
||||
pub mod list_available_api_tokens_for_user;
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod builders;
|
||||
|
||||
pub mod api_tokens;
|
||||
pub mod column_types;
|
||||
pub mod helpers;
|
||||
pub mod tokens;
|
||||
|
||||
@@ -25,6 +25,10 @@ impl Tokens {
|
||||
random_prefix_crockford_token("WR:", 32, false)
|
||||
}
|
||||
|
||||
pub fn new_api_token() -> AnyhowResult<String> {
|
||||
random_prefix_crockford_token("API:", 32, true)
|
||||
}
|
||||
|
||||
// ========== Tokens not typically visible to users ==========
|
||||
|
||||
pub fn new_user() -> AnyhowResult<String> {
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
use actix_http::Error;
|
||||
use actix_http::http::header;
|
||||
use actix_web::HttpResponseBuilder;
|
||||
use actix_web::cookie::Cookie;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{Path, Json};
|
||||
use actix_web::{Responder, web, HttpResponse, error, HttpRequest};
|
||||
use crate::database::enums::record_visibility::RecordVisibility;
|
||||
use crate::database::queries::query_w2l_template::select_w2l_template_by_token;
|
||||
use crate::http_server::web_utils::ip_address::get_request_ip;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::server_state::ServerState;
|
||||
use crate::util::email_to_gravatar::email_to_gravatar;
|
||||
use crate::util::markdown_to_html::markdown_to_html;
|
||||
use database_queries::tokens::Tokens;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use log::{info, warn, log};
|
||||
use regex::Regex;
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::error::DatabaseError;
|
||||
use sqlx::error::Error::Database;
|
||||
use sqlx::mysql::MySqlDatabaseError;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use database_queries::api_tokens::create_api_token::create_api_token_for_user;
|
||||
|
||||
// =============== Request ===============
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateApiTokenRequest {
|
||||
pub idempotency_token: String,
|
||||
}
|
||||
|
||||
// =============== Success Response ===============
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateApiTokenResponse {
|
||||
pub success: bool,
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
// =============== Error Response ===============
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum CreateApiTokenError {
|
||||
BadInput(String),
|
||||
NotAuthorized,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
impl ResponseError for CreateApiTokenError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
CreateApiTokenError::BadInput(_) => StatusCode::BAD_REQUEST,
|
||||
CreateApiTokenError::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
CreateApiTokenError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
serialize_as_json_error(self)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Not using DeriveMore since Clion doesn't understand it.
|
||||
impl fmt::Display for CreateApiTokenError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Handler ===============
|
||||
|
||||
pub async fn create_api_token_handler(
|
||||
http_request: HttpRequest,
|
||||
request: web::Json<CreateApiTokenRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CreateApiTokenError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CreateApiTokenError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CreateApiTokenError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("banned users cannot create API tokens");
|
||||
return Err(CreateApiTokenError::NotAuthorized);
|
||||
}
|
||||
|
||||
let api_token = create_api_token_for_user(
|
||||
&user_session.user_token,
|
||||
&request.idempotency_token,
|
||||
&server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("API token creation errror: {:?}", e);
|
||||
CreateApiTokenError::ServerError
|
||||
})?;
|
||||
|
||||
let response = CreateApiTokenResponse {
|
||||
success: true,
|
||||
api_token
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| CreateApiTokenError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
use actix_http::Error;
|
||||
use actix_http::http::header;
|
||||
use actix_web::HttpResponseBuilder;
|
||||
use actix_web::cookie::Cookie;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{Path, Json};
|
||||
use actix_web::{Responder, web, HttpResponse, error, HttpRequest};
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::database::enums::record_visibility::RecordVisibility;
|
||||
use crate::database::queries::query_w2l_template::select_w2l_template_by_token;
|
||||
use crate::database::query_builders::list_categories_query_builder::ListCategoriesQueryBuilder;
|
||||
use crate::http_server::web_utils::ip_address::get_request_ip;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::response_success_helpers::simple_json_success;
|
||||
use crate::server_state::ServerState;
|
||||
use crate::util::email_to_gravatar::email_to_gravatar;
|
||||
use crate::util::markdown_to_html::markdown_to_html;
|
||||
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
|
||||
use lexical_sort::natural_lexical_cmp;
|
||||
use log::{info, warn, log};
|
||||
use regex::Regex;
|
||||
use sqlx::MySqlPool;
|
||||
use sqlx::error::DatabaseError;
|
||||
use sqlx::error::Error::Database;
|
||||
use sqlx::mysql::MySqlDatabaseError;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use database_queries::api_tokens::list_available_api_tokens_for_user::list_available_api_tokens_for_user;
|
||||
|
||||
// =============== Success Response ===============
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListApiTokensResponse {
|
||||
pub success: bool,
|
||||
pub api_tokens: Vec<ApiToken>,
|
||||
}
|
||||
|
||||
/// Public-facing and optimized (non-irrelevant fields)
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiToken {
|
||||
pub api_token: String,
|
||||
pub maybe_short_description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// =============== Error Response ===============
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum ListApiTokensError {
|
||||
NotAuthorized,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
impl ResponseError for ListApiTokensError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match *self {
|
||||
ListApiTokensError::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
ListApiTokensError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
serialize_as_json_error(self)
|
||||
}
|
||||
}
|
||||
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
impl fmt::Display for ListApiTokensError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Handler ===============
|
||||
|
||||
pub async fn list_api_tokens_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, ListApiTokensError>
|
||||
{
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
ListApiTokensError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(ListApiTokensError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("banned users cannot use API tokens");
|
||||
return Err(ListApiTokensError::NotAuthorized);
|
||||
}
|
||||
|
||||
let api_tokens = list_available_api_tokens_for_user(&user_session.user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("API token query error: {:?}", e);
|
||||
ListApiTokensError::ServerError
|
||||
})?;
|
||||
|
||||
let mut api_tokens = api_tokens.into_iter()
|
||||
.map(|r| {
|
||||
ApiToken {
|
||||
api_token: r.api_token,
|
||||
maybe_short_description: r.maybe_short_description,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ApiToken>>();
|
||||
|
||||
let response = ListApiTokensResponse {
|
||||
success: true,
|
||||
api_tokens,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&response)
|
||||
.map_err(|e| ListApiTokensError::ServerError)?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(body))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod create_api_token;
|
||||
pub mod list_api_tokens;
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api_tokens;
|
||||
pub mod categories;
|
||||
pub mod events;
|
||||
pub mod leaderboard;
|
||||
|
||||
@@ -71,6 +71,8 @@ use crate::http_server::endpoints::w2l::get_w2l_upload_template_job_status::get_
|
||||
use crate::http_server::endpoints::w2l::list_w2l_templates::list_w2l_templates_handler;
|
||||
use crate::http_server::endpoints::w2l::set_w2l_template_mod_approval::set_w2l_template_mod_approval_handler;
|
||||
use crate::http_server::endpoints::misc::detect_locale_handler::detect_locale_handler;
|
||||
use crate::http_server::endpoints::api_tokens::create_api_token::create_api_token_handler;
|
||||
use crate::http_server::endpoints::api_tokens::list_api_tokens::list_api_tokens_handler;
|
||||
|
||||
pub fn add_routes<T, B> (app: App<T, B>) -> App<T, B>
|
||||
where
|
||||
@@ -88,6 +90,7 @@ pub fn add_routes<T, B> (app: App<T, B>) -> App<T, B>
|
||||
app = add_w2l_routes(app); /* /w2l */
|
||||
app = add_category_routes(app); /* /category */
|
||||
app = add_user_profile_routes(app); /* /user */
|
||||
app = add_api_token_routes(app); /* /api_tokens */
|
||||
app = add_twitch_oauth_routes(app); /* /twitch */ // TODO: MAYBE TEMPORARY
|
||||
|
||||
// ==================== ACCOUNT CREATION / SESSION MANAGEMENT ====================
|
||||
@@ -528,6 +531,32 @@ fn add_user_profile_routes<T, B> (app: App<T, B>) -> App<T, B>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== API TOKEN ROUTES ====================
|
||||
|
||||
fn add_api_token_routes<T, B> (app: App<T, B>) -> App<T, B>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(web::scope("/api_tokens")
|
||||
.service(web::resource("/create")
|
||||
.route(web::post().to(create_api_token_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
.service(web::resource("/list")
|
||||
.route(web::get().to(list_api_tokens_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TWITCH ROUTES ====================
|
||||
|
||||
// TODO: Maybe move these into an "oauth-gateway" type http service.
|
||||
|
||||
Reference in new issue
Block a user