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

feat-webgarages-121 (#129)

### Description

Closes #121

### Please confirm

- [x] I am the legal owner or represent the legal owner of all work submitted (including LLM-generated code, if any)
- [x] I consent to my changes being added to this FOSS project
- [x] I have confirmed that this does not add new errors or warnings with `utils/clippy.sh`
- [ ] This PR used LLMs to generate some or all of the code changes

Reviewed-on: https://git.ngram.ca/OpenJam/rc-servers/pulls/129
This commit is contained in:
NG (Graham)
2026-06-15 01:02:52 +00:00
committed by NGnius
parent a222745841
commit d2626bb47f
40 changed files with 4012 additions and 282 deletions

View File

@@ -1,4 +1,4 @@
use actix_web::{get, web::Data, Responder, HttpRequest};
use actix_web::{get, post, web::Data, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
@@ -29,12 +29,12 @@ struct PermissionData {
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> {
pub async fn dashboard_impl(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
log::debug!("Rendering user's dashboard");
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(
@@ -63,3 +63,13 @@ pub async fn get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Bo
}
}
}
#[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> {
dashboard_impl(handlebars_ref, auth, user_opt, req).await
}
#[post("/dashboard")]
pub async fn post(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> {
dashboard_impl(handlebars_ref, auth, user_opt, req).await
}

View File

@@ -0,0 +1,52 @@
use actix_web::{HttpRequest, HttpResponse, Responder, get, http::StatusCode, web::{Data, Path, Query}};
use actix_identity::Identity;
use oj_rc_plugins::vehicle_import::VehicleImportData;
use crate::web::{LoginReturn, try_auth_user};
use crate::api::garage::plugins::ImportPlugins;
#[get("/garages/{id}/export")]
pub async fn get(id: Path<i32>, query: Query<super::PortQuery>, exporter: Data<ImportPlugins>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
match user.garage_by_id(*id).await {
Ok(Some(garage)) => {
let data = VehicleImportData {
cube_data: garage.robot_data,
colour_data: garage.colour_data,
vehicle_name: garage.name,
vehicle_author: Some(user.display_name().to_owned()),
};
match exporter.export_by_name(&query.plugin, &data) {
Ok(export) => {
//export.push(b'\n');
use actix_web::http::header::{ContentDisposition, TryIntoHeaderPair};
let ext = exporter.file_ext(&query.plugin).unwrap();
// TODO sanitise vehicle name and include it in the filename
let dispo = ContentDisposition::attachment(format!("export-{}-{}.{}", &query.plugin, *id, ext));
let (key, val) = dispo.try_into_pair().unwrap();
let mut resp = HttpResponse::with_body(StatusCode::OK, export);
resp.headers_mut().append(key, val);
Ok(resp.map_into_boxed_body())
},
Err(e) => {
log::error!("Failed export of vehicle {} for user {}: {}", id, user.public_id(), e);
Err(super::PluginPortError::from(e).into())
}
}
},
Ok(None) => {
Ok(HttpResponse::NotFound()
.finish())
},
Err(e) => {
log::error!("Failed to load garage ID for user {}: {}", user.public_id(), e);
Ok(HttpResponse::InternalServerError()
.finish())
}
}
}
}
}

View File

@@ -0,0 +1,221 @@
use actix_web::{get, post, web::{Data, Path}, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
use crate::web::{LoginReturn, try_auth_user, render_ok, render_err};
use crate::api::garage::plugins::ImportPlugins;
const FORM_NAME: &str = "garage_import";
#[derive(Serialize, Deserialize)]
struct RenderData {
display_name: String,
public_id: String,
garage: Option<GarageData>,
importers: Vec<String>,
selected_importer: Option<String>,
success: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct GarageData {
garage_id: i32,
slot: i32,
}
async fn import_impl(id: Option<i32>, handlebars_ref: Data<handlebars::Handlebars<'_>>, importer: Data<ImportPlugins>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
let html = if let Some(id) = id {
match user.garage_by_id(id).await {
Ok(Some(garage)) => {
render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: Some(GarageData {
garage_id: id,
slot: garage.slot,
}),
importers: importer.plugin_names()
.map(|x| x.to_owned())
.collect(),
selected_importer: None,
success: None,
},
handlebars_ref.as_ref(),
FORM_NAME,
)
},
Ok(None) => {
log::error!("Failed to find vehicle {} for user {}", id, user.public_id());
render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: Some(GarageData {
garage_id: -1,
slot: -1,
}),
importers: Vec::new(),
selected_importer: None,
success: None,
},
"Vehicle not found".to_owned(),
handlebars_ref.as_ref(),
FORM_NAME,
)
},
Err(e) => {
log::error!("Failed to retrieve vehicle {} for user {}: {}", id, user.public_id(), e);
render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: Some(GarageData {
garage_id: -1,
slot: -1,
}),
importers: Vec::new(),
selected_importer: None,
success: None,
},
e.to_string(),
handlebars_ref.as_ref(),
FORM_NAME,
)
}
}
} else {
render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: None,
importers: importer.plugin_names()
.map(|x| x.to_owned())
.collect(),
selected_importer: None,
success: None,
},
handlebars_ref.as_ref(),
FORM_NAME,
)
};
Ok(
html
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}
#[get("/garages/{id}/import")]
pub async fn get_existing(id: Path<i32>, handlebars_ref: Data<handlebars::Handlebars<'_>>, importer: Data<ImportPlugins>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
import_impl(Some(*id), handlebars_ref, importer, auth, user_opt, req).await
}
#[get("/garages/import")]
pub async fn get_new(handlebars_ref: Data<handlebars::Handlebars<'_>>, importer: Data<ImportPlugins>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
import_impl(None, handlebars_ref, importer, auth, user_opt, req).await
}
#[derive(Debug, actix_multipart::form::MultipartForm)]
struct ImportForm {
//#[multipart]
files: Vec<actix_multipart::form::bytes::Bytes>,
garage_id: actix_multipart::form::text::Text<i32>,
plugin: actix_multipart::form::text::Text<String>,
}
#[post("/garages/import")]
pub async fn post(form: actix_multipart::form::MultipartForm<ImportForm>, handlebars_ref: Data<handlebars::Handlebars<'_>>, importer: Data<ImportPlugins>, parsers: Data<oj_rc_core::cubes::CubeParsers>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
let total_size: usize = form.files.iter().map(|f| f.data.len()).sum();
if form.files.is_empty() {
return Err(super::PluginPortError {
code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid,
}.into());
}
if *form.garage_id != -1 && form.files.len() != 1 {
return Err(super::PluginPortError {
code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode::Invalid,
}.into());
}
for (i, file) in form.files.iter().enumerate() {
#[cfg(debug_assertions)]
log::trace!("import {} file {} data: {:?}", &*form.plugin, i, &file.data[..]);
let import_data = match importer.import_by_name(&form.plugin, &file.data)
.map_err(|e| super::PluginPortError { code: e }) {
Ok(x) => x,
Err(e) => {
let html = render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: None,
importers: importer.plugin_names()
.map(|x| x.to_owned())
.collect(),
selected_importer: Some(form.plugin.0.clone()),
success: None,
},
format!("Failed to import file {}: {}", i, e),
handlebars_ref.as_ref(),
FORM_NAME,
);
return Ok(
html
.respond_to(&req)
.map_into_boxed_body()
);
},
};
let vehicle_data = oj_rc_core::persist::user::VehicleData {
name: import_data.vehicle_name,
slot: -1, // will be overriden in oj_rc_core
robot_data: import_data.cube_data,
colour_data: import_data.colour_data,
weapon_order: Vec::default(), // will be overriden in oj_rc_core
crf_id: None, // irrelevant
was_rated: None, // irrelevant
};
user.save_garage(
vehicle_data,
if *form.garage_id < 0 { None } else { Some(*form.garage_id) }, // multipart crate silliness
parsers.cpu_counter().as_ref(),
parsers.weapon_order().as_ref(),
).await?;
}
let html = render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: None,
importers: importer.plugin_names()
.map(|x| x.to_owned())
.collect(),
selected_importer: Some(form.plugin.0.clone()),
success: Some(format!(
"Imported {} vehicle{} ({:.2}KiB)",
form.files.len(),
if form.files.len() > 1 { "s" } else { "" },
total_size as f64 / 1024.0,
)),
},
handlebars_ref.as_ref(),
FORM_NAME,
);
Ok(
html
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}

View File

@@ -0,0 +1,93 @@
use actix_web::{get, web::{Data, Path}, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
use crate::web::{LoginReturn, try_auth_user, render_ok, render_err};
use crate::api::garage::plugins::ImportPlugins;
const FORM_NAME: &str = "garage_info";
#[derive(Serialize, Deserialize)]
struct RenderData {
display_name: String,
public_id: String,
garage: GarageData,
exporters: Vec<String>,
}
#[derive(Serialize, Deserialize)]
struct GarageData {
garage_id: i32,
slot: i32,
name: String,
}
#[get("/garages/{id}/info")]
pub async fn get(id: Path<i32>, handlebars_ref: Data<handlebars::Handlebars<'_>>, exporter: Data<ImportPlugins>, auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
let html = match user.garage_by_id(*id).await {
Ok(Some(garage)) => {
render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: GarageData {
garage_id: *id,
slot: garage.slot,
name: garage.name.unwrap_or_default(),
},
exporters: exporter.plugin_names()
.map(|x| x.to_owned())
.collect(),
},
handlebars_ref.as_ref(),
FORM_NAME,
)
},
Ok(None) => {
log::error!("Failed to find vehicle {} for user {}", id, user.public_id());
render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: GarageData {
garage_id: -1,
slot: -1,
name: String::default(),
},
exporters: Vec::new(),
},
"Vehicle not found".to_owned(),
handlebars_ref.as_ref(),
FORM_NAME,
)
},
Err(e) => {
log::error!("Failed to retrieve vehicle {} for user {}: {}", id, user.public_id(), e);
render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garage: GarageData {
garage_id: -1,
slot: -1,
name: String::default(),
},
exporters: Vec::new(),
},
e.to_string(),
handlebars_ref.as_ref(),
FORM_NAME,
)
}
};
Ok(
html
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}

View File

@@ -0,0 +1,76 @@
use actix_web::{get, web::Data, Responder, HttpRequest};
use actix_identity::Identity;
use serde::{Serialize, Deserialize};
use crate::web::{LoginReturn, try_auth_user, render_ok, render_err};
const FORM_NAME: &str = "garage_list";
#[derive(Serialize, Deserialize)]
struct RenderData {
display_name: String,
public_id: String,
garages: Vec<GarageData>,
}
#[derive(Serialize, Deserialize)]
struct GarageData {
garage_id: i32,
slot: i32,
name: String,
cpu: i32,
max_cpu: i32,
creation_time_unix: i64,
creation_time_iso: String,
}
#[get("/garages/list")]
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 try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
let html = match user.garages().await {
Ok(mut garage_infos) => {
garage_infos.sort_by_key(|g| g.slot);
render_ok(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garages: garage_infos.into_iter()
.map(|g| GarageData {
garage_id: g.id,
slot: g.slot,
name: g.name,
cpu: g.total_robot_cpu,
max_cpu: g.bay_cpu,
creation_time_unix: g.creation_time,
creation_time_iso: chrono::DateTime::<chrono::Utc>::from_timestamp_secs(g.creation_time).unwrap_or_default().to_rfc3339(),
})
.collect(),
},
handlebars_ref.as_ref(),
FORM_NAME,
)
},
Err(e) => {
log::error!("Failed to retrieve garages for user {}: {}", user.public_id(), e);
render_err(
RenderData {
display_name: user.display_name().to_owned(),
public_id: user.public_id().to_owned(),
garages: Vec::new(),
},
e.to_string(),
handlebars_ref.as_ref(),
FORM_NAME,
)
}
};
Ok(
html
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}

View File

@@ -0,0 +1,37 @@
pub mod list;
pub mod export;
pub mod import;
pub mod info;
pub mod selected;
#[derive(serde::Deserialize)]
struct PortQuery {
pub plugin: String,
}
#[derive(Debug)]
struct PluginPortError {
code: oj_rc_plugins::vehicle_import::VehicleImportErrorCode,
}
impl std::convert::From<oj_rc_plugins::vehicle_import::VehicleImportErrorCode> for PluginPortError {
fn from(value: oj_rc_plugins::vehicle_import::VehicleImportErrorCode) -> Self {
Self {
code: value,
}
}
}
impl core::fmt::Display for PluginPortError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.code)
}
}
impl core::error::Error for PluginPortError {}
impl actix_web::error::ResponseError for PluginPortError {
fn status_code(&self) -> actix_web::http::StatusCode {
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY
}
}

View File

@@ -0,0 +1,29 @@
use actix_web::{get, web::{Data, Redirect}, Responder, HttpRequest};
use actix_identity::Identity;
use crate::web::{LoginReturn, try_auth_user};
#[get("/garages/selected")]
pub async fn get(auth: Data<Box<oj_rc_core::UserImpl>>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
match try_auth_user(user_opt, auth.as_ref(), &req).await? {
LoginReturn::AuthFail(resp) => Ok(resp),
LoginReturn::Success(user) => {
let resp = if let Some(selected_garage) = user.garage_id_selected().await? {
Redirect::to(format!("/garages/{}/info", selected_garage))
.respond_to(&req)
.map_into_boxed_body()
} else {
log::error!("User {} has no garage selected (bad database state?)", user.display_name());
Redirect::to("/garages/list".to_owned())
.respond_to(&req)
.map_into_boxed_body()
};
Ok(
resp
.respond_to(&req)
.map_into_boxed_body()
)
}
}
}

View File

@@ -2,6 +2,7 @@ pub mod dashboard;
pub mod login;
pub mod favicon;
pub mod index;
pub mod garage;
use serde::Serialize;
use actix_web::{web::{Html, Redirect}, Responder};
@@ -51,19 +52,25 @@ async fn try_auth_user(user_opt: Option<actix_identity::Identity>, auth: &oj_rc_
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)),
Ok(user) => {
//log::debug!("Web auth success");
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")
.temporary()
.respond_to(req)
.map_into_boxed_body()
))
}
}
} else {
log::debug!("No user identity, redirecting to login page");
Ok(LoginReturn::AuthFail(
Redirect::to("/login")
.temporary()
.respond_to(req)
.map_into_boxed_body()
))