1
0
mirror of https://git.ngram.ca/OpenJam/rc-servers synced 2026-08-23 23:08:52 +00:00

Create society service with basic functionality and auth #118

This commit is contained in:
NG (Graham)
2026-05-02 11:03:49 -04:00
parent e09aeceed3
commit e3183eb238
51 changed files with 1424 additions and 77 deletions

View File

58
rc_society/src/cli.rs Normal file
View File

@@ -0,0 +1,58 @@
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 = 8002)]
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 fn loaded(&self) -> LoadedArgs {
let assets_path = std::path::PathBuf::from(&self.assets_robocraft);
let data_path = std::path::PathBuf::from(&self.data_robocraft);
let token_path = data_path.join(oj_rc_core::persist::user::TOKEN_SECRET_FILENAME);
let secret = std::fs::read(&token_path).expect("Bad token");
let cookie_key = if secret.len() < 32 {
log::warn!("{} should be >= 32 bytes (extending with zeroes)", token_path.display());
let mut secret_ext = secret.clone();
while secret_ext.len() < 32 {
secret_ext.push(0);
}
actix_web::cookie::Key::derive_from(&secret_ext)
} else {
actix_web::cookie::Key::derive_from(&secret)
};
LoadedArgs {
secret: std::sync::Arc::new(secret.clone()),
cookie_key,
assets: assets_path,
data: data_path,
}
}
}
#[allow(dead_code)]
pub struct LoadedArgs {
pub secret: std::sync::Arc<Vec<u8>>,
pub cookie_key: actix_web::cookie::Key,
pub assets: std::path::PathBuf,
pub data: std::path::PathBuf,
}

78
rc_society/src/main.rs Normal file
View File

@@ -0,0 +1,78 @@
#![forbid(unsafe_code)]
mod cli;
mod api;
mod web;
use actix_web::{App, HttpServer, Responder};
pub static START_TIME: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(-1);
#[actix_web::get("/version")]
async fn version_info() -> 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 config = oj_rc_core::ConfigImpl::load(&cli_args.assets_robocraft)?;
let server_settings = actix_web::web::Data::new(<oj_rc_core::ConfigImpl as oj_rc_core::ConfigProvider<()>>::server_config(&config));
let users = oj_rc_core::UserImpl::load(&cli_args.data_robocraft, &config).await.expect("Bad user data");
let auth_ref = actix_web::web::Data::new(Box::new(users));
let cli_args2 = actix_web::web::Data::new(cli_args.clone());
let loadeds_args = actix_web::web::Data::new(cli_args.loaded());
let mut handlebars_conf = handlebars::Handlebars::new();
let mut dir_conf = handlebars::DirectorySourceOptions::default();
dir_conf.tpl_extension = ".html.hbs".to_owned();
dir_conf.hidden = false;
dir_conf.temporary = false;
handlebars_conf
.register_templates_directory(
std::path::PathBuf::from(&cli_args.assets_robocraft).parent().expect("Bad robocraft asset path").join("templates/rc_society"),
dir_conf,
)
.unwrap();
let handlebars_ref = actix_web::web::Data::new(handlebars_conf);
START_TIME.store(chrono::Utc::now().timestamp(), std::sync::atomic::Ordering::SeqCst);
HttpServer::new(move || {
App::new()
.wrap_fn(|req, srv| {
use actix_web::dev::Service;
log::trace!("Request {} {}", req.method(), req.path());
srv.call(req)
})
.wrap(actix_identity::IdentityMiddleware::default())
.wrap(actix_session::SessionMiddleware::new(
actix_session::storage::CookieSessionStore::default(),
loadeds_args.cookie_key.clone(),
))
.app_data(cli_args2.clone())
.app_data(loadeds_args.clone())
.app_data(handlebars_ref.clone())
.app_data(server_settings.clone())
.app_data(auth_ref.clone())
.service(version_info)
.service(web::login::form_submit)
.service(web::login::form_load)
.service(web::favicon::favicon_standard)
.service(web::dashboard::get)
.service(web::index::get)
})
.bind((cli_args.ip, cli_args.port))?
.run()
.await
}

View File

@@ -0,0 +1,65 @@
use actix_web::{get, web::Data, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
const FORM_NAME: &str = "dashboard";
#[derive(Serialize, Deserialize)]
struct RenderData {
// TODO
display_name: String,
public_id: String,
debug: DebugData,
perms: PermissionData,
}
#[derive(Serialize, Deserialize)]
struct DebugData {
user_id: i32,
creation_time_unix: i64,
creation_time_iso: String,
}
#[derive(Serialize, Deserialize)]
struct PermissionData {
r#mod: bool,
admin: bool,
dev: bool,
royal: bool,
banned: bool,
}
#[get("/dashboard")]
pub async fn get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match super::try_auth_user(user_opt, auth.as_ref(), &req).await? {
super::LoginReturn::AuthFail(resp) => Ok(resp),
super::LoginReturn::Success(user) => {
// TODO
let creation_time = user.creation();
let creation_time_chrono = chrono::DateTime::<chrono::Utc>::from_timestamp_secs(creation_time).unwrap_or_default();
Ok(super::render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
debug: DebugData {
user_id: user.account_id(),
creation_time_unix: creation_time,
creation_time_iso: creation_time_chrono.to_rfc3339(),
},
perms: PermissionData {
r#mod: user.is_mod(),
admin: user.is_admin(),
dev: user.is_dev(),
royal: user.is_royal(),
banned: user.is_banned(),
}
},
handlebars_ref.as_ref(),
FORM_NAME,
)
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}

View File

@@ -0,0 +1,11 @@
use actix_web::{get, web::Data, Responder};
async fn favicon_impl(cli_args: Data<crate::cli::LoadedArgs>) -> impl Responder {
let path = std::path::PathBuf::from(&cli_args.assets).join("favicon.jpg");
actix_files::NamedFile::open_async(path).await
}
#[get("/favicon.ico")]
pub async fn favicon_standard(cli_args: Data<crate::cli::LoadedArgs>) -> impl Responder {
favicon_impl(cli_args).await
}

View File

@@ -0,0 +1,77 @@
use actix_web::{get, web::Data, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
pub const FORM_NAME: &str = "index";
#[derive(Serialize, Deserialize)]
struct RenderData {
is_logged_in: bool,
display_name: Option<String>,
server: ServerDetails,
}
#[derive(Serialize, Deserialize)]
struct ServerDetails {
domain: String,
cdn: String,
auth: String,
min_version: i32,
server_version: String,
start_time_iso: String,
start_time_unix: i64,
}
fn server_details(conf: &oj_rc_core::persist::config::ServerConfig) -> ServerDetails {
let start_time_unix = crate::START_TIME.load(std::sync::atomic::Ordering::Relaxed);
let start_time_chrono = chrono::DateTime::<chrono::Utc>::from_timestamp_secs(start_time_unix).unwrap_or_default();
let version = env!("CARGO_PKG_VERSION");
let git_version = git_version::git_version!(args = ["--always", "--dirty=+"]);
let server_version = format!("{}:{}", version, git_version);
ServerDetails {
domain: conf.domain.clone(),
cdn: conf.cdn_url.clone(),
auth: conf.auth_url.clone(),
min_version: conf.minimum_version,
server_version,
start_time_unix,
start_time_iso: start_time_chrono.to_rfc3339(),
}
}
#[get("/")]
pub async fn get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, server_config: Data<oj_rc_core::persist::config::ServerConfig>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
let server_info = server_details(server_config.as_ref());
if let Some(user) = user_opt {
match super::try_auth_user(Some(user), auth.as_ref(), &req).await? {
super::LoginReturn::AuthFail(resp) => Ok(resp),
super::LoginReturn::Success(user) => {
Ok(super::render_ok(
RenderData {
is_logged_in: true,
display_name: Some(user.display_name().to_owned()),
server: server_info,
},
handlebars_ref.as_ref(),
FORM_NAME,
)
.respond_to(&req)
.map_into_boxed_body()
)
}
}
} else {
Ok(super::render_ok(
RenderData {
is_logged_in: false,
display_name: None,
server: server_info,
},
handlebars_ref.as_ref(),
FORM_NAME,
)
.respond_to(&req)
.map_into_boxed_body()
)
}
}

View File

@@ -0,0 +1,66 @@
use actix_web::{get, post, web::{Data, Form, Redirect}, Responder, HttpRequest, HttpMessage};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
const FORM_NAME: &str = "login";
#[derive(Serialize, Deserialize, Default)]
struct LoginForm {
username: String,
password: String,
}
#[post("/login")]
pub async fn form_submit(form: Form<LoginForm>, auth: Data<Box<oj_rc_core::UserImpl>>, handlebars_ref: Data<handlebars::Handlebars<'_>>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
use oj_rc_core::UserAuthenticator;
let auth_result = auth.login(oj_rc_core::persist::user::UserAuthInfo::Username {
username: form.username.to_owned(),
password: form.password.to_owned(),
}).await;
match auth_result {
Ok(user) => {
let resp = Redirect::to("/dashboard")
.respond_to(&req)
.map_into_boxed_body();
Identity::login(&req.extensions(), user.response.token)?;
Ok(resp)
},
Err(e) => {
Ok(super::render_err(form.0, e.message, handlebars_ref.as_ref(), FORM_NAME)
.respond_to(&req)
.map_into_boxed_body())
}
}
}
#[get("/login")]
pub async fn form_load(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
if let Some(user) = user_opt {
let user_id = user.id()?;
use oj_rc_core::UserAuthenticator;
if auth.verify(user_id).await.is_ok() {
Ok(Redirect::to("/dashboard")
.respond_to(&req)
.map_into_boxed_body())
} else {
Ok(super::render_err(
LoginForm::default(),
"Invalid login token".to_owned(),
handlebars_ref.as_ref(),
FORM_NAME,
)
.respond_to(&req)
.map_into_boxed_body()
)
}
} else {
Ok(super::render_ok(
LoginForm::default(),
handlebars_ref.as_ref(),
FORM_NAME,
)
.respond_to(&req)
.map_into_boxed_body()
)
}
}

71
rc_society/src/web/mod.rs Normal file
View File

@@ -0,0 +1,71 @@
pub mod dashboard;
pub mod login;
pub mod favicon;
pub mod index;
use serde::Serialize;
use actix_web::{web::{Html, Redirect}, Responder};
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)
}
#[derive(Serialize)]
struct Context<T: Serialize> {
form: T,
error: Option<String>,
version: String,
source_url: String,
}
fn render_ok<T: Serialize>(form: T, renderer: &handlebars::Handlebars<'_>, form_name: &str) -> 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 render_err<T: Serialize>(form: T, error: String , renderer: &handlebars::Handlebars<'_>, form_name: &str) -> 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)
}
enum LoginReturn {
Success(Box<dyn oj_rc_core::persist::user::WebUser>),
AuthFail(actix_web::HttpResponse<actix_web::body::BoxBody>),
}
async fn try_auth_user(user_opt: Option<actix_identity::Identity>, auth: &oj_rc_core::UserImpl, req: &actix_web::HttpRequest) -> Result<LoginReturn, actix_web::Error> {
if let Some(user) = user_opt {
let user_id = user.id()?;
match <oj_rc_core::UserImpl as oj_rc_core::UserProvider<()>>::web_authenticate(auth, user_id.clone()).await {
Ok(user) => Ok(LoginReturn::Success(user)),
Err(e) => {
log::warn!("Failed to login with token {}: {} ({:?})", user_id, e.message, e.code);
Ok(LoginReturn::AuthFail(
Redirect::to("/login")
.respond_to(req)
.map_into_boxed_body()
))
}
}
} else {
Ok(LoginReturn::AuthFail(
Redirect::to("/login")
.respond_to(req)
.map_into_boxed_body()
))
}
}