mirror of
https://git.ngram.ca/OpenJam/rc-servers
synced 2026-08-23 23:08:52 +00:00
Move Robocraft auth server to actix_web for better error responses #31
This commit is contained in:
24
rc_auth/Cargo.toml
Normal file
24
rc_auth/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "rc_auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
[dependencies]
|
||||
actix-web.workspace = true
|
||||
actix-files.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
tokio = { version = "1.43", features = [ "rt-multi-thread" ] }
|
||||
clap.workspace = true
|
||||
oj_rc_core = { version = "*", path = "../rc_core" }
|
||||
libfj.workspace = true
|
||||
git-version.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
hex = "0.4"
|
||||
|
||||
handlebars = { version = "5", features = ["dir_source"] }
|
||||
3
rc_auth/run_debug.sh
Executable file
3
rc_auth/run_debug.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
RUST_LOG=debug cargo run
|
||||
31
rc_auth/src/cli.rs
Normal file
31
rc_auth/src/cli.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct CliArgs {
|
||||
/// TCP port on which to accept connections
|
||||
#[arg(short, long, default_value_t = 8001)]
|
||||
pub port: u16,
|
||||
|
||||
/// IP Address on which to accept connections
|
||||
#[arg(long, default_value_t = {"127.0.0.1".to_string()})]
|
||||
pub ip: String,
|
||||
|
||||
/// Assets root
|
||||
#[arg(long, default_value_t = {"../assets/robocraft".to_string()})]
|
||||
pub assets_robocraft: String,
|
||||
|
||||
/// User data root
|
||||
#[arg(long, default_value_t = {"../data/robocraft".to_string()})]
|
||||
pub data_robocraft: String,
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
pub fn get() -> Self {
|
||||
Self::parse()
|
||||
}
|
||||
|
||||
pub async fn preloaded(self) -> crate::robocraft::RcConfig {
|
||||
crate::robocraft::RcConfig::from_args(&self).await
|
||||
}
|
||||
}
|
||||
60
rc_auth/src/main.rs
Normal file
60
rc_auth/src/main.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
mod cli;
|
||||
mod robocraft;
|
||||
|
||||
use actix_web::{App, HttpServer, Responder};
|
||||
|
||||
#[actix_web::get("/")]
|
||||
async fn index() -> impl Responder {
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let git_version = git_version::git_version!(args = ["--always", "--dirty=+"]);
|
||||
let authors = env!("CARGO_PKG_AUTHORS");
|
||||
let license = env!("CARGO_PKG_LICENSE");
|
||||
let repo = env!("CARGO_PKG_REPOSITORY");
|
||||
format!("{} {}:{} by [{}]\n{}\n{}", name, version, git_version, authors, license, repo)
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init();
|
||||
let cli_args = cli::CliArgs::get();
|
||||
let cli_args2 = actix_web::web::Data::new(cli_args.clone());
|
||||
let rc_preloaded = actix_web::web::Data::new(cli_args.clone().preloaded().await);
|
||||
|
||||
let mut handlebars = handlebars::Handlebars::new();
|
||||
handlebars
|
||||
.register_templates_directory(
|
||||
std::path::PathBuf::from(&cli_args.assets_robocraft).parent().expect("Bad robocraft asset path").join("templates"),
|
||||
handlebars::DirectorySourceOptions {
|
||||
tpl_extension: ".html.hbs".to_owned(),
|
||||
hidden: false,
|
||||
temporary: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let handlebars_ref = actix_web::web::Data::new(handlebars);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(cli_args2.clone())
|
||||
.app_data(rc_preloaded.clone())
|
||||
.app_data(handlebars_ref.clone())
|
||||
.service(index)
|
||||
.service(robocraft::registration::form_submit)
|
||||
.service(robocraft::registration::form_load)
|
||||
.service(robocraft::registration::favicon)
|
||||
.service(robocraft::email::email_password_auth)
|
||||
.service(robocraft::steam::steam_auth)
|
||||
.service(robocraft::username::user_password_auth)
|
||||
/*.service(robocraft::live_data::live_data_json)
|
||||
.service(robocraft::user_avatar::get)
|
||||
.service(robocraft::clan_avatar::get)
|
||||
.service(robocraft::brawl_data::get)
|
||||
.service(robocraft::campaign_data::get)
|
||||
.service(robocraft::factory::arc::get)
|
||||
.service(robocraft::favicon::get)*/
|
||||
})
|
||||
.bind((cli_args.ip, cli_args.port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
25
rc_auth/src/robocraft/email.rs
Normal file
25
rc_auth/src/robocraft/email.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use oj_rc_core::UserAuthenticator;
|
||||
use actix_web::{post, web::{Data, Json}};
|
||||
|
||||
#[post("/authenticate/email/game")]
|
||||
pub async fn email_password_auth(body: Json<libfj::robocraft::EmailUserAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
|
||||
log::info!("Authenticating {} user {}", body.target, body.display_name);
|
||||
let payload = libfj::robocraft::TokenPayload {
|
||||
public_id: body.display_name.clone(),
|
||||
display_name: body.display_name.clone(),
|
||||
robocraft_name: body.display_name.clone(),
|
||||
email_address: body.email_address.clone(),
|
||||
email_verified: true,
|
||||
flags: Vec::new(),
|
||||
};
|
||||
let user_info = oj_rc_core::persist::user::UserInfo {
|
||||
payload,
|
||||
extra: oj_rc_core::persist::user::ExtraUserInfo::Email { password: body.password.clone() },
|
||||
};
|
||||
let response = config.account_provider.login(user_info).await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to authenticate {} user {}: {}", body.target, body.display_name, e.message);
|
||||
super::ErrorTy::from_err(e)
|
||||
})?;
|
||||
Ok(Json(response.response))
|
||||
}
|
||||
63
rc_auth/src/robocraft/mod.rs
Normal file
63
rc_auth/src/robocraft/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
pub mod email;
|
||||
pub mod registration;
|
||||
pub mod steam;
|
||||
pub mod username;
|
||||
|
||||
pub struct RcConfig {
|
||||
//pub data: std::path::PathBuf,
|
||||
pub account_provider: oj_rc_core::UserImpl,
|
||||
pub assets: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl RcConfig {
|
||||
pub async fn from_args(args: &crate::cli::CliArgs) -> Self {
|
||||
let conf = oj_rc_core::persist::config::ConfigImpl::load(&args.assets_robocraft).expect("Bad config data");
|
||||
Self {
|
||||
account_provider: oj_rc_core::UserImpl::load(&args.data_robocraft, &conf).await.expect("Invalid Robocraft user data"),
|
||||
//data: args.data_robocraft.clone().into(),
|
||||
assets: args.assets_robocraft.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(self) struct ErrorTy {
|
||||
json: libfj::robocraft::ErrorInfo,
|
||||
}
|
||||
|
||||
impl ErrorTy {
|
||||
pub fn from_err(error: oj_rc_core::persist::user::AuthError) -> Self {
|
||||
Self {
|
||||
json: libfj::robocraft::ErrorInfo {
|
||||
error_code: error.code.to_str(),
|
||||
error_message: error.message,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::error::ResponseError for ErrorTy {
|
||||
fn status_code(&self) -> actix_web::http::StatusCode {
|
||||
actix_web::http::StatusCode::UNAUTHORIZED
|
||||
}
|
||||
|
||||
fn error_response(&self) -> actix_web::HttpResponse<actix_web::body::BoxBody> {
|
||||
actix_web::HttpResponse::with_body(
|
||||
self.status_code(),
|
||||
serde_json::to_string(&self.json).unwrap(),
|
||||
).map_into_boxed_body()
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for ErrorTy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
//use core::fmt::Write;
|
||||
write!(f, "({}) {}", self.json.error_code, self.json.error_message)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for ErrorTy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ErrorTy")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
197
rc_auth/src/robocraft/registration.rs
Normal file
197
rc_auth/src/robocraft/registration.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use oj_rc_core::UserAuthenticator;
|
||||
use actix_web::{get, post, web::{Data, Form, Html}, Responder};
|
||||
//use rocket_dyn_templates::{Template, context};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
const FORM_NAME: &str = "rc_register";
|
||||
const FORM_NAME_SUCCESS: &str = "rc_register_success";
|
||||
|
||||
const VALID_CHARS: &[char] = &[
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
'_',
|
||||
];
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct RegisterForm {
|
||||
display_name: String,
|
||||
password: String,
|
||||
password_c: String,
|
||||
email: Option<String>,
|
||||
steam_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Context {
|
||||
form: RegisterForm,
|
||||
error: Option<String>,
|
||||
version: String,
|
||||
source_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ContextSuccess {
|
||||
display_name: String,
|
||||
id: i32,
|
||||
version: String,
|
||||
source_url: String,
|
||||
}
|
||||
|
||||
fn version_string() -> String {
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
//let license = env!("CARGO_PKG_LICENSE");
|
||||
//let repo = env!("CARGO_PKG_REPOSITORY");
|
||||
format!("OpenJam {} {}", name, version)
|
||||
}
|
||||
|
||||
fn all_valid_chars(s: &str) -> bool {
|
||||
for c in s.chars() {
|
||||
if !VALID_CHARS.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn registration_ok(form: RegisterForm, renderer: &handlebars::Handlebars<'_>) -> Html {
|
||||
let rendered = renderer.render(FORM_NAME, &Context {
|
||||
form,
|
||||
error: None,
|
||||
version: version_string(),
|
||||
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
|
||||
}).unwrap();
|
||||
Html::new(rendered)
|
||||
}
|
||||
|
||||
fn registration_err(form: RegisterForm, error: String , renderer: &handlebars::Handlebars<'_>) -> Html {
|
||||
let rendered = renderer.render(FORM_NAME, &Context {
|
||||
form,
|
||||
error: Some(error),
|
||||
version: version_string(),
|
||||
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
|
||||
}).unwrap();
|
||||
Html::new(rendered)
|
||||
}
|
||||
|
||||
#[post("/register")]
|
||||
pub async fn form_submit(form: Form<RegisterForm>, config: Data<super::RcConfig>, handlebars_ref: Data<handlebars::Handlebars<'_>>) -> Result<Html, actix_web::error::Error> {
|
||||
// password confirmation validation
|
||||
if form.password != form.password_c {
|
||||
return Ok(registration_err(form.into_inner(), "Passwords do not match".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
if form.password.len() < 8 {
|
||||
return Ok(registration_err(form.into_inner(), "Password too short (minimum 8 characters)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
if form.password.len() > 128 {
|
||||
return Ok(registration_err(form.into_inner(), "Password too long (maximum 128 characters)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
|
||||
// email validation
|
||||
let actual_email: Option<String>;
|
||||
if let Some(email) = &form.email {
|
||||
if email.is_empty() {
|
||||
actual_email = None;
|
||||
} else {
|
||||
if !email.contains('@') {
|
||||
return Ok(registration_err(form.into_inner(), "Email must contain @".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
let email_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::Email(email.to_owned()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to check if user email {} exists: {}", email, e);
|
||||
actix_web::error::ErrorInternalServerError(e)
|
||||
})?;
|
||||
if email_exists {
|
||||
return Ok(registration_err(form.into_inner(), "Email already registered".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
actual_email = Some(email.to_owned());
|
||||
}
|
||||
} else {
|
||||
actual_email = None;
|
||||
}
|
||||
|
||||
// steam id validation
|
||||
let actual_steam_id: Option<u64>;
|
||||
if let Some(steam_id) = &form.steam_id {
|
||||
if steam_id.is_empty() {
|
||||
actual_steam_id = None;
|
||||
} else {
|
||||
let steam_id = match steam_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_e) => return Ok(registration_err(form.into_inner(), "Invalid SteamID (not an integer)".to_owned(), &*handlebars_ref)),
|
||||
};
|
||||
if steam_id >= 7656120_0000000000 || steam_id < 7656119_0000000000 {
|
||||
return Ok(registration_err(form.into_inner(), "Invalid SteamID (should be like 7656119XXXXXXXXXX)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
let steam_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::SteamId(steam_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to check if user steam id {} exists: {}", steam_id, e);
|
||||
actix_web::error::ErrorInternalServerError(e)
|
||||
})?;
|
||||
if steam_exists {
|
||||
return Ok(registration_err(form.into_inner(), "SteamID already registered".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
actual_steam_id = Some(steam_id);
|
||||
}
|
||||
} else {
|
||||
actual_steam_id = None;
|
||||
}
|
||||
|
||||
// username validation
|
||||
if form.display_name.len() < 4 {
|
||||
return Ok(registration_err(form.into_inner(), "Username too short (minimum 4 characters)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
if form.display_name.len() > 32 {
|
||||
return Ok(registration_err(form.into_inner(), "Username too long (maximum 32 characters)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
if !all_valid_chars(&form.display_name.to_lowercase()) {
|
||||
return Ok(registration_err(form.into_inner(), "Invalid username (only alphanumerics and _ allowed)".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
let username_exists = config.account_provider.user_exists(oj_rc_core::persist::user::UserId::Username(form.display_name.to_owned()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to check if user name {} exists: {}", form.display_name, e);
|
||||
actix_web::error::ErrorInternalServerError(e)
|
||||
})?;
|
||||
if username_exists {
|
||||
return Ok(registration_err(form.into_inner(), "Username already registered".to_owned(), &*handlebars_ref));
|
||||
}
|
||||
|
||||
let user_id = match config.account_provider.register(oj_rc_core::persist::user::RegistrationInfo {
|
||||
display_name: form.display_name.clone(),
|
||||
password: form.password.clone(),
|
||||
email: actual_email,
|
||||
steam_id: actual_steam_id,
|
||||
}).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return Ok(registration_err(form.into_inner(), format!("Registration failed: {}", e), &*handlebars_ref));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Html::new(handlebars_ref.render(FORM_NAME_SUCCESS, &ContextSuccess {
|
||||
display_name: form.display_name.clone(),
|
||||
id: user_id,
|
||||
version: version_string(),
|
||||
source_url: env!("CARGO_PKG_REPOSITORY").to_owned(),
|
||||
}).unwrap()))
|
||||
}
|
||||
|
||||
#[get("/register")]
|
||||
pub async fn form_load(handlebars_ref: Data<handlebars::Handlebars<'_>>) -> Html {
|
||||
registration_ok(RegisterForm {
|
||||
display_name: "".to_owned(),
|
||||
password: "".to_owned(),
|
||||
password_c: "".to_owned(),
|
||||
email: None,
|
||||
steam_id: None,
|
||||
}, &*handlebars_ref)
|
||||
}
|
||||
|
||||
#[get("/robocraft/favicon")]
|
||||
pub async fn favicon(config: Data<super::RcConfig>) -> impl Responder {
|
||||
let path = config.assets.join("favicon.jpg");
|
||||
actix_files::NamedFile::open_async(path).await
|
||||
}
|
||||
65
rc_auth/src/robocraft/steam.rs
Normal file
65
rc_auth/src/robocraft/steam.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use oj_rc_core::UserAuthenticator;
|
||||
use actix_web::{post, web::{Data, Json}};
|
||||
|
||||
fn authenticate_steam_ticket(hex_ticket: &str) -> Result<u64, ()> {
|
||||
get_steam_id_from_ticket_hex(hex_ticket)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to parse steamId: {}", e);
|
||||
()
|
||||
})
|
||||
}
|
||||
|
||||
fn get_steam_id_from_ticket_hex(hex_ticket: &str) -> Result<u64, hex::FromHexError> {
|
||||
let decoded_ticket = hex::decode(hex_ticket)?;
|
||||
if decoded_ticket.len() < 72 {
|
||||
Err(hex::FromHexError::InvalidStringLength)
|
||||
} else {
|
||||
Ok(get_steam_id_from_ticket(&decoded_ticket))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_steam_id_from_ticket(ticket: &[u8]) -> u64 {
|
||||
get_u64_with_offset(&ticket, 12 /* also at 64 ??? */) // should be 76600000000000000 > number > 76500000000000000
|
||||
}
|
||||
|
||||
fn get_u64_with_offset(arr: &[u8], start: usize) -> u64 {
|
||||
let arr_actual: [u8; 8] = [
|
||||
arr[start],
|
||||
arr[start+1],
|
||||
arr[start+2],
|
||||
arr[start+3],
|
||||
arr[start+4],
|
||||
arr[start+5],
|
||||
arr[start+6],
|
||||
arr[start+7],
|
||||
];
|
||||
u64::from_le_bytes(arr_actual)
|
||||
}
|
||||
|
||||
#[post("/authenticate/steam/game")]
|
||||
pub async fn steam_auth(body: Json<libfj::robocraft::SteamAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
|
||||
let steam_id = authenticate_steam_ticket(&body.steam_ticket)
|
||||
.map_err(|_| super::ErrorTy::from_err(oj_rc_core::persist::user::AuthError {
|
||||
message: "Bad SteamId".to_owned(),
|
||||
code: oj_rc_core::data::error_codes::AuthErrorCode::BadCredentials,
|
||||
}))?;
|
||||
log::info!("Authenticating {} steam user {}", body.target, steam_id);
|
||||
let payload = libfj::robocraft::TokenPayload {
|
||||
public_id: steam_id.to_string(),
|
||||
display_name: steam_id.to_string(),
|
||||
robocraft_name: steam_id.to_string(),
|
||||
email_address: format!("{}.rc.steam@ngram.ca", steam_id),
|
||||
email_verified: true,
|
||||
flags: Vec::new(),
|
||||
};
|
||||
let user_info = oj_rc_core::persist::user::UserInfo {
|
||||
payload,
|
||||
extra: oj_rc_core::persist::user::ExtraUserInfo::Steam { id: steam_id },
|
||||
};
|
||||
let response = config.account_provider.login(user_info).await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to authenticate {} steam user {}: {}", body.target, steam_id, e.message);
|
||||
super::ErrorTy::from_err(e)
|
||||
})?;
|
||||
Ok(Json(response.response))
|
||||
}
|
||||
25
rc_auth/src/robocraft/username.rs
Normal file
25
rc_auth/src/robocraft/username.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use oj_rc_core::UserAuthenticator;
|
||||
use actix_web::{post, web::{Data, Json}};
|
||||
|
||||
#[post("/authenticate/robocraft/game")]
|
||||
pub async fn user_password_auth(body: Json<libfj::robocraft::EmailUserAuthenticationPayload>, config: Data<super::RcConfig>) -> Result<Json<libfj::robocraft::AuthenticationResponseInfo>, super::ErrorTy> {
|
||||
log::info!("Authenticating {} user {}", body.target, body.display_name);
|
||||
let payload = libfj::robocraft::TokenPayload {
|
||||
public_id: body.display_name.clone(),
|
||||
display_name: body.display_name.clone(),
|
||||
robocraft_name: body.display_name.clone(),
|
||||
email_address: body.email_address.clone(),
|
||||
email_verified: true,
|
||||
flags: Vec::new(),
|
||||
};
|
||||
let user_info = oj_rc_core::persist::user::UserInfo {
|
||||
payload,
|
||||
extra: oj_rc_core::persist::user::ExtraUserInfo::Username { password: body.password.clone() },
|
||||
};
|
||||
let response = config.account_provider.login(user_info).await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to authenticate {} user {}: {}", body.target, body.display_name, e.message);
|
||||
super::ErrorTy::from_err(e)
|
||||
})?;
|
||||
Ok(Json(response.response))
|
||||
}
|
||||
Reference in New Issue
Block a user