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

rc_society dashboard display (#131)

### Description

Closes #130

### 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/131
This commit is contained in:
NG (Graham)
2026-06-20 21:48:09 +00:00
committed by NGnius
parent d2626bb47f
commit b597e1337d
19 changed files with 741 additions and 109 deletions

1
Cargo.lock generated
View File

@@ -3326,6 +3326,7 @@ dependencies = [
"log", "log",
"oj_convert", "oj_convert",
"oj_rc_core", "oj_rc_core",
"oj_rc_factory",
"oj_rc_plugins", "oj_rc_plugins",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -72,8 +72,20 @@
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Garages</h4> <h4 class="access-box-title">Garages</h4>
<div class="box-details"> <div class="box-details">
<div class="box-details-key">Total</div> <!-- TODO --> <div class="box-details-key">Total</div>
<div class="box-details-value">{{form.debug.user_id}}/{{form.debug.user_id}}</div> <div class="box-details-value">{{form.garage.total}}</div>
<div class="box-details-key">Regulars</div>
<div class="box-details-value">{{form.garage.regular_total}}</div>
<div class="box-details-key">Megabots</div>
<div class="box-details-value">{{form.garage.mega_total}}</div>
<div class="box-details-key">Empty</div>
<div class="box-details-value">{{form.garage.empty_total}}</div>
<div class="box-details-key">Selected Slot</div>
<div class="box-details-value">#{{form.garage.selected_slot}}</div>
{{#if form.garage.bytes_total }}
<div class="box-details-key">Storage</div>
<div class="box-details-value">{{form.garage.bytes_total}}B</div>
{{/if}}
</div> </div>
<a href="/garages/list"> <a href="/garages/list">
<div class="access-box-banner"> <div class="access-box-banner">
@@ -84,10 +96,12 @@
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Factory</h4> <h4 class="access-box-title">Factory</h4>
<div class="box-details"> <div class="box-details">
<div class="box-details-key">Total</div> <!-- TODO --> <div class="box-details-key">Factory Garages</div>
<div class="box-details-value">{{form.debug.user_id}}/{{form.debug.user_id}}</div> <div class="box-details-value">{{form.factory.garages_from_factory}}</div>
<div class="box-details-key">Uploads</div>
<div class="box-details-value">{{form.factory.uploads_total}}</div>
</div> </div>
<a href="#"> <!-- TODO --> <a href="{{form.debug.factory}}">
<div class="access-box-banner"> <div class="access-box-banner">
View My Uploads View My Uploads
</div> </div>
@@ -95,6 +109,32 @@
</div> </div>
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Account</h4> <h4 class="access-box-title">Account</h4>
<div class="box-details">
{{#if form.account.is_custom_avatar}}
<div class="box-details-key">Avatar</div>
<div class="box-details-value">
<a href="{{form.debug.cdn}}/customavatar/Live/{{form.public_id}}" download="user_{{form.display_name}}.jpg" target="_blank" type="media/jpeg" referrerpolicy="no-referrer">
<img src="{{form.debug.cdn}}/customavatar/Live/{{form.public_id}}" alt="Custom avatar picture for {{form.display_name}}" style="margin-top: 5px;"/>
</a>
</div>
{{else}}
<div class="box-details-key">Avatar</div>
<div class="box-details-value">{{form.account.avatar_id}}</div>
{{/if}}
<div class="box-details-key">Rank</div>
<div class="box-details-value">{{form.account.rank}}</div>
<div class="box-details-key">Currency</div>
<div class="box-details-value">
Robits: {{form.account.currencies.robits}}<br/>
GC: {{form.account.currencies.gc}}<br/>
TP: {{form.account.currencies.tp}}<br/>
XP: {{form.account.currencies.xp}}<br/>
</div>
<div class="box-details-key">Games</div>
<div class="box-details-value">{{form.account.games}}</div>
<div class="box-details-key">Premium Until</div>
<div class="box-details-value"><time datetime="{{form.account.premium_until_iso}}">{{form.account.premium_until_iso}}</time></div>
</div>
<a href="#"> <!-- TODO --> <a href="#"> <!-- TODO -->
<div class="access-box-banner"> <div class="access-box-banner">
Manage My Account Manage My Account
@@ -104,8 +144,20 @@
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Sanctions</h4> <h4 class="access-box-title">Sanctions</h4>
<div class="box-details"> <div class="box-details">
<div class="box-details-key">Total</div> <!-- TODO --> <div class="box-details-key">Total</div>
<div class="box-details-value">{{form.debug.user_id}}</div> <div class="box-details-value">{{form.sanction.total}}</div>
{{#if (ne form.sanction.pending 0)}}
<div class="box-details-key">Pending</div>
<div class="box-details-value">{{form.sanction.pending}}</div>
{{/if}}
<div class="box-details-key">Warnings</div>
<div class="box-details-value">{{form.sanction.warns}}</div>
<div class="box-details-key">Mutes</div>
<div class="box-details-value">{{form.sanction.mutes}}</div>
{{#if form.sanction.muted_until_iso}}
<div class="box-details-key">Muted Until</div>
<div class="box-details-value"><time datetime="{{form.sanction.muted_until_iso}}">{{form.sanction.muted_until_iso}}</time></div>
{{/if}}
</div> </div>
<a href="#"> <!-- TODO --> <a href="#"> <!-- TODO -->
<div class="access-box-banner"> <div class="access-box-banner">
@@ -116,17 +168,40 @@
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Social</h4> <h4 class="access-box-title">Social</h4>
<div class="box-details"> <div class="box-details">
<div class="box-details-key">Clan</div> <!-- TODO --> {{#if form.social.clan}}
<div class="box-details-value">{{form.display_name}}</div> <div class="box-details-key">Clan</div>
<div class="box-details-key">Friends</div> <!-- TODO --> <div class="box-details-value">
<div class="box-details-value">{{form.debug.user_id}}</div> <span style="display: inline-block; text-align: center; width: 100%;">{{form.social.clan.name}}</span>
<a href="{{form.debug.cdn}}/clanavatar/Live/{{form.social.clan.name}}" download="clan_{{form.social.clan.name}}.jpg" target="_blank" type="media/jpeg" referrerpolicy="no-referrer">
<img src="{{form.debug.cdn}}/clanavatar/Live/{{form.social.clan.name}}" alt="Clan avatar picture for {{form.social.clan.name}}" style="margin-top: 5px;"/>
</a>
<span style="display: inline-block; font-size: 10px; text-align: center; margin: 5px auto; width: 100%;">{{form.social.clan.ty}}</span>
<span>{{form.social.clan.description}}</span>
</div>
{{else}}
<div class="box-details-key">Clan</div>
<div class="box-details-value">[no clan]</div>
{{/if}}
<div class="box-details-key">Friends</div>
<div class="box-details-value">
{{form.social.friends}}
{{#if (ne form.social.friends form.social.friends_of)}}
= {{form.social.friends_of}}[!]
{{/if}}
</div>
<div class="box-details-key">Chats</div>
<div class="box-details-value">
{{#each form.social.chats}}
{{this}}<br/>
{{/each}}
</div>
</div> </div>
</div> </div>
<div class="access-box"> <div class="access-box">
<h4 class="access-box-title">Federation</h4> <h4 class="access-box-title">Federation</h4>
<div class="box-details"> <div class="box-details">
<div class="box-details-key">Enabled</div> <!-- TODO --> <div class="box-details-key">TODO</div> <!-- TODO -->
<div class="box-details-value">{{form.perms.royal}}</div> <div class="box-details-value">TODO</div>
</div> </div>
<a href="#"> <!-- TODO --> <a href="#"> <!-- TODO -->
<div class="access-box-banner"> <div class="access-box-banner">

View File

@@ -61,9 +61,11 @@
<div class="box-details-key">Domain</div> <div class="box-details-key">Domain</div>
<div class="box-details-value">{{form.server.domain}}</div> <div class="box-details-value">{{form.server.domain}}</div>
<div class="box-details-key">CDN</div> <div class="box-details-key">CDN</div>
<div class="box-details-value">{{form.server.cdn}}</div> <div class="box-details-value"><a class="box-details-link" href="{{form.server.cdn}}">{{form.server.cdn}}</a></div>
<div class="box-details-key">Auth</div> <div class="box-details-key">Auth</div>
<div class="box-details-value">{{form.server.auth}}</div> <div class="box-details-value"><a class="box-details-link" href="{{form.server.auth}}">{{form.server.auth}}</a></div>
<div class="box-details-key">Factory</div>
<div class="box-details-value"><a class="box-details-link" href="{{form.server.factory}}">{{form.server.factory}}</a></div>
<div class="box-details-key">Client Version</div> <div class="box-details-key">Client Version</div>
<div class="box-details-value">>={{form.server.min_version}}</div> <div class="box-details-value">>={{form.server.min_version}}</div>
<div class="box-details-key">Server Version</div> <div class="box-details-key">Server Version</div>
@@ -73,9 +75,17 @@
</div> </div>
</div> </div>
<div class="access-box"> <div class="access-box">
Grid item 2 <h4 class="access-box-title">Server Links</h4>
<div class="box-details">
<div class="box-details-key">Feedback</div>
<div class="box-details-value"><a class="box-details-link" href="{{form.links.feedback}}">{{form.links.feedback}}</a></div>
<div class="box-details-key">Support</div>
<div class="box-details-value"><a class="box-details-link" href="{{form.links.support}}">{{form.links.support}}</a></div>
<div class="box-details-key">Wiki</div>
<div class="box-details-value"><a class="box-details-link" href="{{form.links.wiki}}">{{form.links.wiki}}</a></div>
</div>
</div> </div>
<div class="access-box"> <!--<div class="access-box">
Grid item 3 Grid item 3
</div> </div>
<div class="access-box"> <div class="access-box">
@@ -92,7 +102,7 @@
</div> </div>
<div class="access-box"> <div class="access-box">
Grid item 8 Grid item 8
</div> </div>-->
</div> </div>

View File

@@ -44,6 +44,11 @@ a:link { color: darkgrey; }
a:hover { color: white; } a:hover { color: white; }
a:visited { color: lightgrey; } a:visited { color: lightgrey; }
a:active { color: white; } a:active { color: white; }
a.box-details-link { text-decoration-line: underline; }
a.box-details-link:link { color: black; }
a.box-details-link:hover { color: white; }
a.box-details-link:visited { color: black; }
a.box-details-link:active { color: white; }
form { form {
padding-top: 25px; padding-top: 25px;
padding-left: 25px; padding-left: 25px;

View File

@@ -28,6 +28,16 @@ impl oj_rc_factory::VehicleFactoryAdapter for Factory {
} }
} }
async fn count(&self, query: libfj::robocraft::ListQuery) -> Result<usize, Box<dyn std::error::Error>> {
match self {
Self::Arc(x) => x.count(query).await,
Self::Primary(x) => x.count(query).await,
Self::Web(x) => x.count(query).await,
Self::Custom(x) => x.count(query).await,
Self::None => Ok(0),
}
}
async fn upload(&self, vehicle: oj_rc_factory::VehicleUploadInfo) -> Result<oj_rc_factory::VehicleThumbnailInfo, Box<dyn std::error::Error>> { async fn upload(&self, vehicle: oj_rc_factory::VehicleUploadInfo) -> Result<oj_rc_factory::VehicleThumbnailInfo, Box<dyn std::error::Error>> {
match self { match self {
Self::Arc(x) => x.upload(vehicle).await, Self::Arc(x) => x.upload(vehicle).await,

View File

@@ -323,6 +323,7 @@ impl <C: Clone + Send> super::ConfigProvider<C> for CubeConfig {
cdn_url: self.settings.server.cdn_url.trim_end_matches('/').to_owned(), cdn_url: self.settings.server.cdn_url.trim_end_matches('/').to_owned(),
auth_url: self.settings.server.auth_url.trim_end_matches('/').to_owned(), auth_url: self.settings.server.auth_url.trim_end_matches('/').to_owned(),
intercom_url: self.settings.server.intercom_url.trim_end_matches('/').to_owned(), intercom_url: self.settings.server.intercom_url.trim_end_matches('/').to_owned(),
factory_url: self.settings.server.factory_url.trim_end_matches('/').to_owned(),
minimum_version: self.settings.server.min_version as i32, minimum_version: self.settings.server.min_version as i32,
dos_protect: self.settings.server.dos_protection, dos_protect: self.settings.server.dos_protection,
maintenance_message: self.settings.server.maintenance_message.clone(), maintenance_message: self.settings.server.maintenance_message.clone(),

View File

@@ -100,6 +100,7 @@ pub struct ServerConfig {
pub cdn_url: String, pub cdn_url: String,
pub auth_url: String, pub auth_url: String,
pub intercom_url: String, pub intercom_url: String,
pub factory_url: String,
pub minimum_version: i32, pub minimum_version: i32,
pub dos_protect: bool, pub dos_protect: bool,
pub maintenance_message: Option<String>, pub maintenance_message: Option<String>,

View File

@@ -96,6 +96,8 @@ pub struct ServerSettings {
pub auth_url: String, pub auth_url: String,
#[serde(default = "default_intercom_root_url")] #[serde(default = "default_intercom_root_url")]
pub intercom_url: String, pub intercom_url: String,
#[serde(default = "default_factory_url")]
pub factory_url: String,
#[serde(default = "default_feedback_url")] #[serde(default = "default_feedback_url")]
pub feedback_url: String, pub feedback_url: String,
#[serde(default = "default_support_url")] #[serde(default = "default_support_url")]
@@ -132,6 +134,7 @@ fn default_server_conf() -> ServerSettings {
cdn_url: default_cdn_root_url(), cdn_url: default_cdn_root_url(),
auth_url: default_auth_root_url(), auth_url: default_auth_root_url(),
intercom_url: default_intercom_root_url(), intercom_url: default_intercom_root_url(),
factory_url: default_factory_url(),
feedback_url: default_feedback_url(), feedback_url: default_feedback_url(),
support_url: default_support_url(), support_url: default_support_url(),
wiki_url: default_wiki_url(), wiki_url: default_wiki_url(),
@@ -157,6 +160,10 @@ fn default_intercom_root_url() -> String {
"ws://127.0.0.1:8001".to_owned() "ws://127.0.0.1:8001".to_owned()
} }
fn default_factory_url() -> String {
"http://127.0.0.1:8012".to_owned()
}
fn default_feedback_url() -> String { fn default_feedback_url() -> String {
"https://mstdn.ca/@ngram".to_owned() "https://mstdn.ca/@ngram".to_owned()
} }

View File

@@ -11,7 +11,7 @@ mod inventory;
pub use inventory::{UnlockedParts, UnlockOverride}; pub use inventory::{UnlockedParts, UnlockOverride};
mod traits; mod traits;
pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo}; pub use traits::{UserProvider, User, UserToken, UserSlots, UserSlotData, VehicleData, UserAuthInfo, UserLoginInfo, UserAuthenticator, NewSlotData, UserId, RegistrationInfo, VehicleUploadData, ChatUser, AvatarInfo, GetAvatarInfo, ControlData, ControlType, CustomisationData, GetCustomisationData, SetSanction, SanctionType, LobbyUser, GameDescriptor, PlayerLobbyDescriptor, MultiplayerUser, PlayerScore, MultiplayerError, MultiplayerErrorCode, PlayerDescriptor, GameEventSetter, CurrentGameEvent, AuthError, IntercomUser, FakePlayers, ResolvedVehicle, CommonUser, IntercomListener, UserRole, SocialUser, SocialUserC, CurrencyType, CurrencyOp, MatchRewards, SingleplayerUser, PurchaseResult, FactoryUser, FriendInviteReturn, FriendData, FriendInviteStatus, SocialInfo, ClanData, ClanMember, ClanMemberRank, ClanType, ClanSearchQuery, ClanInviteData, Userless, GameOverrides, WebUser, GarageWebInfo, GarageWebStats, SanctionWebStats, AccountWebStats, SocialWebStats};
pub mod intercom; pub mod intercom;
pub use intercom::generate_token as generate_intercom_token; pub use intercom::generate_token as generate_intercom_token;

View File

@@ -468,7 +468,7 @@ pub trait CommonUser: Send + Sync {
async fn currency(&self, ty: CurrencyType, op: CurrencyOp) -> Result<u64, polariton_server::operations::SimpleOpError>; async fn currency(&self, ty: CurrencyType, op: CurrencyOp) -> Result<u64, polariton_server::operations::SimpleOpError>;
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
pub enum CurrencyType { pub enum CurrencyType {
Free, Free,
Paid, Paid,
@@ -680,6 +680,10 @@ pub trait WebUser: CommonUser {
async fn garage_by_id(&self, id: i32) -> Result<Option<VehicleData>, Box<dyn std::error::Error>>; async fn garage_by_id(&self, id: i32) -> Result<Option<VehicleData>, Box<dyn std::error::Error>>;
async fn save_garage(&self, vehicle: crate::persist::user::VehicleData, garage_id: Option<i32>, cpu_counter: &crate::cubes::CpuListParser, weapon_orderer: &crate::cubes::WeaponListParser) -> Result<(), Box<dyn std::error::Error>>; async fn save_garage(&self, vehicle: crate::persist::user::VehicleData, garage_id: Option<i32>, cpu_counter: &crate::cubes::CpuListParser, weapon_orderer: &crate::cubes::WeaponListParser) -> Result<(), Box<dyn std::error::Error>>;
async fn garage_id_selected(&self) -> Result<Option<i32>, Box<dyn std::error::Error>>; async fn garage_id_selected(&self) -> Result<Option<i32>, Box<dyn std::error::Error>>;
async fn garage_stats(&self) -> Result<GarageWebStats, Box<dyn std::error::Error>>;
async fn account_stats(&self) -> Result<AccountWebStats, Box<dyn std::error::Error>>;
async fn sanction_stats(&self) -> Result<SanctionWebStats, Box<dyn std::error::Error>>;
async fn social_stats(&self) -> Result<SocialWebStats, Box<dyn std::error::Error>>;
} }
pub struct GarageWebInfo { pub struct GarageWebInfo {
@@ -691,6 +695,41 @@ pub struct GarageWebInfo {
pub creation_time: i64, pub creation_time: i64,
} }
pub struct GarageWebStats {
pub vehicle_total: u64,
pub regular_vehicle_total: u64,
pub mega_vehicle_total: u64,
pub empty_vehicle_total: u64,
pub factory_vehicle_total: u64,
pub storage_bytes_total: Option<u64>,
pub selected_garage: i32,
}
pub struct AccountWebStats {
pub currencies: std::collections::HashMap<CurrencyType, u64>,
pub games_played: u64,
pub avatar_id: Option<i32>,
pub premium_until: i64,
pub rank: u32,
}
pub struct SanctionWebStats {
pub total: u64,
pub pending_total: u64,
pub warn_total: u64,
pub mute_total: u64,
pub muted_until: Option<i64>,
}
pub struct SocialWebStats {
pub clan: Option<ClanData>,
/// Friend source is user
pub friends_total: u64,
/// Friend target is user
pub friends_of_total: u64,
pub chats: Vec<String>,
}
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Userless: Send + Sync { pub trait Userless: Send + Sync {
async fn lobby_state_listener(&self) -> Result<super::IntercomListener<super::intercom::IntercomLobbyStateMessage>, reqwest_websocket::Error>; async fn lobby_state_listener(&self) -> Result<super::IntercomListener<super::intercom::IntercomLobbyStateMessage>, reqwest_websocket::Error>;

View File

@@ -106,4 +106,100 @@ impl super::WebUser for super::account_json::UserData {
Ok(self.db.garage_selected(self.account.id).await? Ok(self.db.garage_selected(self.account.id).await?
.map(|x| x.id)) .map(|x| x.id))
} }
async fn garage_stats(&self) -> Result<super::GarageWebStats, Box<dyn std::error::Error>> {
let vehicle_count = self.db.garage_count_by_user_id(self.account.id).await?;
let regular_count = self.db.garage_count_by_user_id_between(self.account.id, 2_000, 1).await?;
let mega_count = self.db.garage_count_by_user_id_between(self.account.id, u32::MAX as u64, 2_000).await?;
let empty_count = self.db.garage_count_by_user_id_between(self.account.id, 0, 0).await?;
let factory_count = self.db.garage_count_by_user_id_factory(self.account.id).await?;
let storage_size = self.db.garage_storage_by_user_id(self.account.id).await?;
let selected_garage_slot = self.db.garage_selected(self.account.id).await?.map(|g| g.slot).unwrap_or_default();
Ok(super::GarageWebStats {
vehicle_total: vehicle_count,
regular_vehicle_total: regular_count,
mega_vehicle_total: mega_count,
empty_vehicle_total: empty_count,
factory_vehicle_total: factory_count,
storage_bytes_total: storage_size,
selected_garage: selected_garage_slot,
})
}
async fn account_stats(&self) -> Result<super::AccountWebStats, Box<dyn std::error::Error>> {
let user_aux_data = self.db.user_aux_by_user_id(self.account.id).await?;
let mut currency_map = std::collections::HashMap::with_capacity(8);
let mut avatar_id = None;
let mut premium_until = 0;
let mut user_rank = 0;
for row in user_aux_data.iter() {
match row.descriptor {
oj_rc_database::schema::user_aux::Descriptor::UserXP => {
currency_map.insert(super::CurrencyType::Experience, row.data.parse().unwrap_or_default());
},
oj_rc_database::schema::user_aux::Descriptor::TechPoints => {
currency_map.insert(super::CurrencyType::TechPoints, row.data.parse().unwrap_or_default());
},
oj_rc_database::schema::user_aux::Descriptor::UserFreeCurrency => {
currency_map.insert(super::CurrencyType::Free, row.data.parse().unwrap_or_default());
},
oj_rc_database::schema::user_aux::Descriptor::UserPaidCurrency => {
currency_map.insert(super::CurrencyType::Paid, row.data.parse().unwrap_or_default());
},
oj_rc_database::schema::user_aux::Descriptor::AvatarId => {
avatar_id = row.data.parse::<i32>().ok();
},
oj_rc_database::schema::user_aux::Descriptor::PremiumExpiry => {
premium_until = row.data.parse().unwrap_or_default();
},
oj_rc_database::schema::user_aux::Descriptor::UserRank => {
user_rank = row.data.parse().unwrap_or_default();
},
_ => {},
}
}
let game_count = self.db.count_games_by_user_id(self.account.id).await?;
Ok(super::AccountWebStats {
currencies: currency_map,
games_played: game_count,
avatar_id,
premium_until,
rank: user_rank,
})
}
async fn sanction_stats(&self) -> Result<super::SanctionWebStats, Box<dyn std::error::Error>> {
let all_count = self.db.count_sanctions_by_user_id(self.account.id).await?;
let pending_count = self.db.count_sanctions_by_user_id_and_ack(self.account.id, false).await?;
let warn_count = self.db.count_sanctions_by_user_id_and_descriptor(self.account.id, oj_rc_database::schema::sanction::Descriptor::Warn).await?;
let mute_count = self.db.count_sanctions_by_user_id_and_descriptor(self.account.id, oj_rc_database::schema::sanction::Descriptor::Mute).await?;
let first_active_mute = self.db.sanction_by_user_id_and_descriptor_and_active(self.account.id, oj_rc_database::schema::sanction::Descriptor::Mute).await?;
Ok(super::SanctionWebStats {
total: all_count,
pending_total: pending_count,
warn_total: warn_count,
mute_total: mute_count,
muted_until: first_active_mute.map(|s| s.creation_time + s.duration.unwrap_or_default()),
})
}
async fn social_stats(&self) -> Result<super::SocialWebStats, Box<dyn std::error::Error>> {
let clan_opt = self.db.clan_by_user_id(self.account.id).await?
.map(|(clan, _myself)| super::ClanData {
name: clan.name,
description: clan.description,
ty: super::ClanType::db_to_core(&clan.variant),
size: 0,
});
let friends_count = self.db.count_friends_by_user_id(self.account.id, oj_rc_database::schema::friend::FriendStatus::Accepted).await?;
let friends_of_count = self.db.count_friends_target_by_user_id(self.account.id, oj_rc_database::schema::friend::FriendStatus::Accepted).await?;
let subbed_chats_aux = self.db.user_aux_by_user_id_and_descriptor(self.account.id, oj_rc_database::schema::user_aux::Descriptor::SubscribedChannels).await?;
let chat_list: Vec<String> = subbed_chats_aux.and_then(|aux| serde_json::from_str(&aux.data).ok()).unwrap_or_default();
Ok(super::SocialWebStats {
clan: clan_opt,
friends_total: friends_count,
friends_of_total: friends_of_count,
chats: chat_list,
})
}
} }

View File

@@ -33,7 +33,7 @@ impl FactoryDatabase {
.and_then(|(vehicle, user_opt)| user_opt.map(|user| (vehicle, user)))) .and_then(|(vehicle, user_opt)| user_opt.map(|user| (vehicle, user))))
} }
async fn list_query(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<(crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo)>, sea_orm::DbErr> { fn build_list_query(&self, query: &libfj::robocraft::ListQuery) -> sea_orm::Selector<sea_orm::SelectTwoModel<crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo>> {
let mut query_builder = crate::schema::factory::vehicle::Entity::find() let mut query_builder = crate::schema::factory::vehicle::Entity::find()
.filter(crate::schema::factory::vehicle::Column::RemovedDate.is_null()) .filter(crate::schema::factory::vehicle::Column::RemovedDate.is_null())
.filter(crate::schema::factory::vehicle::Column::BanDate.is_null()) .filter(crate::schema::factory::vehicle::Column::BanDate.is_null())
@@ -91,7 +91,7 @@ impl FactoryDatabase {
query_builder = query_builder.filter(crate::schema::factory::vehicle::Column::Cpu.lte(query.maximum_cpu as u32)); query_builder = query_builder.filter(crate::schema::factory::vehicle::Column::Cpu.lte(query.maximum_cpu as u32));
} }
let query_builder = query_builder query_builder
.find_also_related(crate::schema::user::Entity) .find_also_related(crate::schema::user::Entity)
.select_only() .select_only()
// WARNING: jank sea_orm query generation (fix) // WARNING: jank sea_orm query generation (fix)
@@ -115,8 +115,11 @@ impl FactoryDatabase {
.column_as(crate::schema::factory::vehicle::Column::CubeAmounts, "A_cube_amounts") .column_as(crate::schema::factory::vehicle::Column::CubeAmounts, "A_cube_amounts")
.column_as(crate::schema::user::Column::PublicId, "B_public_id") .column_as(crate::schema::user::Column::PublicId, "B_public_id")
.column_as(crate::schema::user::Column::DisplayName, "B_display_name") .column_as(crate::schema::user::Column::DisplayName, "B_display_name")
.into_model::<crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo>(); .into_model::<crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo>()
//.into_partial_model::<crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo>(); }
async fn list_query(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<(crate::schema::factory::vehicle_query::VehicleInfo, crate::schema::factory::vehicle_query::VehicleOwnerInfo)>, sea_orm::DbErr> {
let query_builder = self.build_list_query(&query);
let pagination = query_builder.paginate(self.orm.as_ref(), query.page_size as u64); let pagination = query_builder.paginate(self.orm.as_ref(), query.page_size as u64);
Ok(pagination.fetch_page((query.page - 1) as u64).await? Ok(pagination.fetch_page((query.page - 1) as u64).await?
.into_iter() .into_iter()
@@ -124,6 +127,11 @@ impl FactoryDatabase {
.collect()) .collect())
} }
async fn count_list_query(&self, query: libfj::robocraft::ListQuery) -> Result<u64, sea_orm::DbErr> {
let query_builder = self.build_list_query(&query);
query_builder.count(self.orm.as_ref()).await
}
fn thumbnail_url(&self, id: i32) -> String { fn thumbnail_url(&self, id: i32) -> String {
format!("{}/roboshop/Live/{}", self.cdn, id) format!("{}/roboshop/Live/{}", self.cdn, id)
} }
@@ -207,6 +215,11 @@ impl VehicleFactoryAdapter for FactoryDatabase {
}).collect()) }).collect())
} }
async fn count(&self, query: libfj::robocraft::ListQuery) -> Result<usize, Box<dyn std::error::Error>> {
let count = self.count_list_query(query).await?;
Ok(count as usize)
}
async fn upload(&self, vehicle: VehicleUploadInfo) -> Result<VehicleThumbnailInfo, Box<dyn std::error::Error>> { async fn upload(&self, vehicle: VehicleUploadInfo) -> Result<VehicleThumbnailInfo, Box<dyn std::error::Error>> {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let entity = crate::schema::factory::vehicle::ActiveModel { let entity = crate::schema::factory::vehicle::ActiveModel {

View File

@@ -1,5 +1,5 @@
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, sea_query::ExprTrait}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, sea_query::ExprTrait, sqlx::types::chrono};
pub struct Database { pub struct Database {
orm: std::sync::Arc<sea_orm::DatabaseConnection>, orm: std::sync::Arc<sea_orm::DatabaseConnection>,
@@ -224,6 +224,32 @@ impl Database {
.await .await
} }
pub async fn garage_count_by_user_id_between(&self, user_id: i32, max_cpu: u64, min_cpu: u64) -> Result<u64, sea_orm::DbErr> {
crate::schema::garage::Entity::find()
.filter(crate::schema::garage::Column::UserId.eq(user_id))
.filter(crate::schema::garage::Column::TotalRobotCpu.lte(max_cpu))
.filter(crate::schema::garage::Column::TotalRobotCpu.gte(min_cpu))
.count(self.orm.as_ref())
.await
}
pub async fn garage_count_by_user_id_factory(&self, user_id: i32) -> Result<u64, sea_orm::DbErr> {
crate::schema::garage::Entity::find()
.filter(crate::schema::garage::Column::UserId.eq(user_id))
.filter(crate::schema::garage::Column::CrfId.is_not_null())
.count(self.orm.as_ref())
.await
}
pub async fn garage_storage_by_user_id(&self, _user_id: i32) -> Result<Option<u64>, sea_orm::DbErr> {
let size = match self.orm.as_ref() {
// TODO implement support in other databases
//sea_orm::DatabaseConnection::SqlxPostgresPoolConnection(_) => None,
_ => None,
};
Ok(size)
}
pub async fn garage_max_slot_by_user_id(&self, user_id: i32) -> Result<i32, sea_orm::DbErr> { pub async fn garage_max_slot_by_user_id(&self, user_id: i32) -> Result<i32, sea_orm::DbErr> {
let result = crate::schema::garage::Entity::find() let result = crate::schema::garage::Entity::find()
.select_only() .select_only()
@@ -373,6 +399,32 @@ impl Database {
.await .await
} }
pub async fn count_sanctions_by_user_id(&self, user_id: i32) -> Result<u64, sea_orm::DbErr> {
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
//.order_by_asc(crate::schema::sanction::Column::CreationTime)
.count(self.orm.as_ref())
.await
}
pub async fn count_sanctions_by_user_id_and_ack(&self, user_id: i32, is_acked: bool) -> Result<u64, sea_orm::DbErr> {
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
.filter(crate::schema::sanction::Column::Acknowledged.eq(is_acked))
//.order_by_asc(crate::schema::sanction::Column::CreationTime)
.count(self.orm.as_ref())
.await
}
pub async fn count_sanctions_by_user_id_and_descriptor(&self, user_id: i32, desc: crate::schema::sanction::Descriptor) -> Result<u64, sea_orm::DbErr> {
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
.filter(crate::schema::sanction::Column::Descriptor.eq(desc))
//.order_by_asc(crate::schema::sanction::Column::CreationTime)
.count(self.orm.as_ref())
.await
}
pub async fn sanctions_by_user_id(&self, user_id: i32) -> Result<Vec<crate::schema::sanction::Model>, sea_orm::DbErr> { pub async fn sanctions_by_user_id(&self, user_id: i32) -> Result<Vec<crate::schema::sanction::Model>, sea_orm::DbErr> {
crate::schema::sanction::Entity::find() crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id)) .filter(crate::schema::sanction::Column::UserId.eq(user_id))
@@ -382,6 +434,19 @@ impl Database {
.await .await
} }
pub async fn sanction_by_user_id_and_descriptor_and_active(&self, user_id: i32, desc: crate::schema::sanction::Descriptor) -> Result<Option<crate::schema::sanction::Model>, sea_orm::DbErr> {
let now = chrono::Utc::now().timestamp();
let expiry_expr = sea_orm::sea_query::Expr::col(crate::schema::sanction::Column::CreationTime).add(sea_orm::sea_query::Expr::col(crate::schema::sanction::Column::Duration));
crate::schema::sanction::Entity::find()
.filter(crate::schema::sanction::Column::UserId.eq(user_id))
.filter(crate::schema::sanction::Column::Descriptor.eq(desc))
.filter(crate::schema::sanction::Column::Duration.is_not_null())
.filter(expiry_expr.clone().gte(now))
.order_by_asc(crate::schema::sanction::Column::CreationTime)
.one(self.orm.as_ref())
.await
}
pub async fn insert_sanction(&self, entity: crate::schema::sanction::ActiveModel) -> Result<crate::schema::sanction::Model, sea_orm::DbErr> { pub async fn insert_sanction(&self, entity: crate::schema::sanction::ActiveModel) -> Result<crate::schema::sanction::Model, sea_orm::DbErr> {
entity.insert(self.orm.as_ref()).await entity.insert(self.orm.as_ref()).await
} }
@@ -400,6 +465,13 @@ impl Database {
.await .await
} }
pub async fn count_games_by_user_id(&self, user_id: i32) -> Result<u64, sea_orm::DbErr> {
crate::schema::multiplayer_game_player::Entity::find()
.filter(crate::schema::multiplayer_game_player::Column::UserId.eq(user_id))
.count(self.orm.as_ref())
.await
}
pub async fn game_by_user_id_and_completion(&self, user_id: i32, is_complete: bool) -> Result<Option<crate::schema::multiplayer_game::Model>, sea_orm::DbErr> { pub async fn game_by_user_id_and_completion(&self, user_id: i32, is_complete: bool) -> Result<Option<crate::schema::multiplayer_game::Model>, sea_orm::DbErr> {
Ok(crate::schema::multiplayer_game::Entity::find() Ok(crate::schema::multiplayer_game::Entity::find()
.find_also_related(crate::schema::multiplayer_game_player::Entity) .find_also_related(crate::schema::multiplayer_game_player::Entity)
@@ -610,6 +682,24 @@ impl Database {
.await .await
} }
pub async fn count_friends_by_user_id(&self, user_id: i32, status_in: crate::schema::friend::FriendStatus) -> Result<u64, sea_orm::DbErr> {
crate::schema::friend::Entity::find()
.filter(crate::schema::friend::Column::FriendSource.eq(user_id))
.filter(crate::schema::friend::Column::State.eq(status_in))
//.order_by_asc(crate::schema::friend::Column::CreationTime)
.count(self.orm.as_ref())
.await
}
pub async fn count_friends_target_by_user_id(&self, user_id: i32, status_in: crate::schema::friend::FriendStatus) -> Result<u64, sea_orm::DbErr> {
crate::schema::friend::Entity::find()
.filter(crate::schema::friend::Column::FriendTarget.eq(user_id))
.filter(crate::schema::friend::Column::State.eq(status_in))
//.order_by_asc(crate::schema::friend::Column::CreationTime)
.count(self.orm.as_ref())
.await
}
pub async fn friends_by_user_id(&self, user_id: i32, status_not_in: impl IntoIterator<Item=crate::schema::friend::FriendStatus>) -> Result<Vec<(crate::schema::friend::Model, crate::schema::user::Model)>, sea_orm::DbErr> { pub async fn friends_by_user_id(&self, user_id: i32, status_not_in: impl IntoIterator<Item=crate::schema::friend::FriendStatus>) -> Result<Vec<(crate::schema::friend::Model, crate::schema::user::Model)>, sea_orm::DbErr> {
Ok(crate::schema::friend::Entity::find() Ok(crate::schema::friend::Entity::find()
.find_also_related(crate::schema::user::Entity) .find_also_related(crate::schema::user::Entity)

View File

@@ -66,55 +66,8 @@ impl ArcAdapter {
serde_json::to_string(&str_map).unwrap_or_else(|_| "{}".to_string()) serde_json::to_string(&str_map).unwrap_or_else(|_| "{}".to_string())
} }
}
#[async_trait::async_trait] fn listquery_to_db(&self, query: &libfj::robocraft::ListQuery) -> sea_orm::Select<super::entities::robot_metadata::Entity> {
impl crate::VehicleFactoryAdapter for ArcAdapter {
async fn vehicle(&self, id: i32) -> Result<Option<(crate::VehicleInfo, crate::VehicleQueryInfo)>, Box<dyn std::error::Error>> {
log::debug!("Get vehicle by id {}", id);
let cubes = super::entities::robot_cubes::Entity::find_by_id(id as u32).one(&self.orm);
let meta = super::entities::robot_metadata::Entity::find_by_id(id as u32).one(&self.orm);
let cubes = cubes.await?;
let meta = meta.await?;
if let (Some(cubes), Some(meta)) = (cubes, meta) {
use base64::Engine;
Ok(Some((
crate::VehicleInfo {
id: id as _,
cube_data: base64::prelude::BASE64_STANDARD.decode(cubes.cube_data.as_bytes()).unwrap_or_default(),
colour_data: base64::prelude::BASE64_STANDARD.decode(cubes.colour_data.as_bytes()).unwrap_or_default(),
},
crate::VehicleQueryInfo {
id: meta.id as _,
name: meta.name,
description: meta.description,
thumbnail: self.thumbnail_url(meta.thumbnail, meta.id),
added_by: if self.spoof_users && meta.added_date.as_str() < "2025-01-23" { hex::encode(meta.added_by.as_bytes()) } else { meta.added_by },
added_by_display_name: meta.added_by_display_name,
added_date: crate::traits::parse_rc_date(&meta.added_date).unwrap_or_default(),
expiry_date: if self.ignore_expiry { chrono::Utc::now() + chrono::Duration::weeks(2) } else { crate::traits::parse_rc_date(&meta.expiry_date).unwrap_or_default() },
cpu: meta.cpu as _,
total_robot_ranking: meta.total_robot_ranking as _,
rent_count: meta.rent_count as _,
buy_count: meta.buy_count as _,
buyable: meta.buyable != 0,
removed_date: Default::default(),
ban_date: Default::default(),
featured: meta.featured != 0,
banner_message: Default::default(),
combat_rating: meta.combat_rating,
cosmetic_rating: meta.cosmetic_rating,
cube_amounts: self.parse_cube_amounts(&cubes.cube_amounts),
}
)))
} else {
Ok(None)
}
}
async fn list(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<crate::VehicleQueryInfo>, Box<dyn std::error::Error>> {
log::debug!("Search vehicles with query {:?}", query);
let mut query_builder = super::entities::robot_metadata::Entity::find(); let mut query_builder = super::entities::robot_metadata::Entity::find();
match query.order { match query.order {
libfj::robocraft::FactoryOrderType::Suggested => { query_builder = query_builder.order_by_desc(super::entities::robot_metadata::Column::RentCount); }, libfj::robocraft::FactoryOrderType::Suggested => { query_builder = query_builder.order_by_desc(super::entities::robot_metadata::Column::RentCount); },
@@ -184,7 +137,58 @@ impl crate::VehicleFactoryAdapter for ArcAdapter {
log::debug!("Expiry must exceed `{}`", now_str); log::debug!("Expiry must exceed `{}`", now_str);
query_builder = query_builder.filter(super::entities::robot_metadata::Column::ExpiryDate.gte(now_str)) query_builder = query_builder.filter(super::entities::robot_metadata::Column::ExpiryDate.gte(now_str))
} }
let query_params = query_builder; query_builder
}
}
#[async_trait::async_trait]
impl crate::VehicleFactoryAdapter for ArcAdapter {
async fn vehicle(&self, id: i32) -> Result<Option<(crate::VehicleInfo, crate::VehicleQueryInfo)>, Box<dyn std::error::Error>> {
log::debug!("Get vehicle by id {}", id);
let cubes = super::entities::robot_cubes::Entity::find_by_id(id as u32).one(&self.orm);
let meta = super::entities::robot_metadata::Entity::find_by_id(id as u32).one(&self.orm);
let cubes = cubes.await?;
let meta = meta.await?;
if let (Some(cubes), Some(meta)) = (cubes, meta) {
use base64::Engine;
Ok(Some((
crate::VehicleInfo {
id: id as _,
cube_data: base64::prelude::BASE64_STANDARD.decode(cubes.cube_data.as_bytes()).unwrap_or_default(),
colour_data: base64::prelude::BASE64_STANDARD.decode(cubes.colour_data.as_bytes()).unwrap_or_default(),
},
crate::VehicleQueryInfo {
id: meta.id as _,
name: meta.name,
description: meta.description,
thumbnail: self.thumbnail_url(meta.thumbnail, meta.id),
added_by: if self.spoof_users && meta.added_date.as_str() < "2025-01-23" { hex::encode(meta.added_by.as_bytes()) } else { meta.added_by },
added_by_display_name: meta.added_by_display_name,
added_date: crate::traits::parse_rc_date(&meta.added_date).unwrap_or_default(),
expiry_date: if self.ignore_expiry { chrono::Utc::now() + chrono::Duration::weeks(2) } else { crate::traits::parse_rc_date(&meta.expiry_date).unwrap_or_default() },
cpu: meta.cpu as _,
total_robot_ranking: meta.total_robot_ranking as _,
rent_count: meta.rent_count as _,
buy_count: meta.buy_count as _,
buyable: meta.buyable != 0,
removed_date: Default::default(),
ban_date: Default::default(),
featured: meta.featured != 0,
banner_message: Default::default(),
combat_rating: meta.combat_rating,
cosmetic_rating: meta.cosmetic_rating,
cube_amounts: self.parse_cube_amounts(&cubes.cube_amounts),
}
)))
} else {
Ok(None)
}
}
async fn list(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<crate::VehicleQueryInfo>, Box<dyn std::error::Error>> {
log::debug!("Search vehicles with query {:?}", query);
let query_params = self.listquery_to_db(&query);
// FIXME add support for query.prepend_featured_bot // FIXME add support for query.prepend_featured_bot
let metadata_pages = query_params.paginate(&self.orm, query.page_size as u64); let metadata_pages = query_params.paginate(&self.orm, query.page_size as u64);
@@ -230,6 +234,14 @@ impl crate::VehicleFactoryAdapter for ArcAdapter {
Ok(infos) Ok(infos)
} }
async fn count(&self, query: libfj::robocraft::ListQuery) -> Result<usize, Box<dyn std::error::Error>> {
log::debug!("Count vehicles with query {:?}", query);
let query_params = self.listquery_to_db(&query);
query_params.count(&self.orm).await
.map(|len| len as usize)
.map_err(|e| Box::new(e) as _)
}
async fn upload(&self, vehicle: crate::VehicleUploadInfo) -> Result<crate::VehicleThumbnailInfo, Box<dyn std::error::Error>> { async fn upload(&self, vehicle: crate::VehicleUploadInfo) -> Result<crate::VehicleThumbnailInfo, Box<dyn std::error::Error>> {
if self.is_readonly { if self.is_readonly {
return Ok(crate::VehicleThumbnailInfo { return Ok(crate::VehicleThumbnailInfo {

View File

@@ -2,6 +2,11 @@
pub trait VehicleFactoryAdapter: Send + Sync + 'static { pub trait VehicleFactoryAdapter: Send + Sync + 'static {
async fn vehicle(&self, id: i32) -> Result<Option<(VehicleInfo, VehicleQueryInfo)>, Box<dyn std::error::Error>>; async fn vehicle(&self, id: i32) -> Result<Option<(VehicleInfo, VehicleQueryInfo)>, Box<dyn std::error::Error>>;
async fn list(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<VehicleQueryInfo>, Box<dyn std::error::Error>>; async fn list(&self, query: libfj::robocraft::ListQuery) -> Result<Vec<VehicleQueryInfo>, Box<dyn std::error::Error>>;
// used by society web dashboard
async fn count(&self, query: libfj::robocraft::ListQuery) -> Result<usize, Box<dyn std::error::Error>> {
Ok(self.list(query).await?.len())
}
async fn upload(&self, vehicle: VehicleUploadInfo) -> Result<VehicleThumbnailInfo, Box<dyn std::error::Error>>; async fn upload(&self, vehicle: VehicleUploadInfo) -> Result<VehicleThumbnailInfo, Box<dyn std::error::Error>>;
async fn rate_vehicle(&self, id: i32, combat: i32, cosmetic: i32) -> Result<(), Box<dyn std::error::Error>>; async fn rate_vehicle(&self, id: i32, combat: i32, cosmetic: i32) -> Result<(), Box<dyn std::error::Error>>;
/// Just update any purchase trackers /// Just update any purchase trackers

View File

@@ -23,6 +23,7 @@ clap.workspace = true
handlebars = { version = "6", features = ["dir_source"] } handlebars = { version = "6", features = ["dir_source"] }
oj_rc_core = { version = "*", path = "../rc_core" } oj_rc_core = { version = "*", path = "../rc_core" }
oj_rc_plugins = { version = "*", path = "../rc_plugins" } oj_rc_plugins = { version = "*", path = "../rc_plugins" }
oj_rc_factory = { version = "*", path = "../rc_factory" }
oj_convert.workspace = true oj_convert.workspace = true
libfj.workspace = true libfj.workspace = true
git-version.workspace = true git-version.workspace = true

View File

@@ -26,6 +26,7 @@ async fn main() -> std::io::Result<()> {
let config = oj_rc_core::ConfigImpl::load(&cli_args.assets_robocraft)?; 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 server_settings = actix_web::web::Data::new(<oj_rc_core::ConfigImpl as oj_rc_core::ConfigProvider<()>>::server_config(&config));
let server_links = actix_web::web::Data::new(<oj_rc_core::ConfigImpl as oj_rc_core::ConfigProvider<()>>::url_links(&config));
let parsers = oj_rc_core::cubes::CubeParsers::new(&config); let parsers = oj_rc_core::cubes::CubeParsers::new(&config);
@@ -36,6 +37,8 @@ async fn main() -> std::io::Result<()> {
let parsers_ref = actix_web::web::Data::new(parsers); let parsers_ref = actix_web::web::Data::new(parsers);
let users = oj_rc_core::UserImpl::load(&cli_args.data_robocraft, &config).await.expect("Bad user data"); let users = oj_rc_core::UserImpl::load(&cli_args.data_robocraft, &config).await.expect("Bad user data");
let factory = <oj_rc_core::persist::config::ConfigImpl as oj_rc_core::ConfigProvider<()>>::factory(&config, &|| users.factory_impl()).await.expect("Bad vehicle factory (CRF) config");
let factory_ref = actix_web::web::Data::new(factory);
let auth_ref = actix_web::web::Data::new(Box::new(users)); let auth_ref = actix_web::web::Data::new(Box::new(users));
let cli_args2 = actix_web::web::Data::new(cli_args.clone()); let cli_args2 = actix_web::web::Data::new(cli_args.clone());
@@ -72,9 +75,11 @@ async fn main() -> std::io::Result<()> {
.app_data(loadeds_args.clone()) .app_data(loadeds_args.clone())
.app_data(handlebars_ref.clone()) .app_data(handlebars_ref.clone())
.app_data(server_settings.clone()) .app_data(server_settings.clone())
.app_data(server_links.clone())
.app_data(auth_ref.clone()) .app_data(auth_ref.clone())
.app_data(importers_ref.clone()) .app_data(importers_ref.clone())
.app_data(parsers_ref.clone()) .app_data(parsers_ref.clone())
.app_data(factory_ref.clone())
.service(version_info) .service(version_info)
.service(web::login::form_submit) .service(web::login::form_submit)
.service(web::login::form_load) .service(web::login::form_load)

View File

@@ -6,11 +6,15 @@ const FORM_NAME: &str = "dashboard";
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct RenderData { struct RenderData {
// TODO
display_name: String, display_name: String,
public_id: String, public_id: String,
debug: DebugData, debug: DebugData,
perms: PermissionData, perms: PermissionData,
garage: GarageData,
factory: FactoryData,
account: AccountData,
sanction: SanctionData,
social: SocialData,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -18,6 +22,8 @@ struct DebugData {
user_id: i32, user_id: i32,
creation_time_unix: i64, creation_time_unix: i64,
creation_time_iso: String, creation_time_iso: String,
cdn: String,
factory: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -29,7 +35,59 @@ struct PermissionData {
banned: bool, banned: bool,
} }
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> { #[derive(Serialize, Deserialize)]
struct GarageData {
total: u64,
regular_total: u64,
mega_total: u64,
empty_total: u64,
bytes_total: Option<u64>, // not always supported
selected_slot: i32,
}
#[derive(Serialize, Deserialize)]
struct FactoryData {
garages_from_factory: u64,
uploads_total: usize,
}
#[derive(Serialize, Deserialize)]
struct AccountData {
currencies: std::collections::HashMap<String, u64>,
games: u64,
avatar_id: String,
is_custom_avatar: bool,
premium_until_unix: i64,
premium_until_iso: String,
rank: u32,
}
#[derive(Serialize, Deserialize)]
struct SanctionData {
total: u64,
pending: u64,
warns: u64,
mutes: u64,
muted_until_unix: Option<i64>,
muted_until_iso: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct ClanData {
name: String,
description: String,
ty: String,
}
#[derive(Serialize, Deserialize)]
struct SocialData {
clan: Option<ClanData>,
friends: u64,
friends_of: u64,
chats: Vec<String>,
}
pub async fn dashboard_impl(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, factory: Data<oj_rc_core::factory::Factory>, server_config: Data<oj_rc_core::persist::config::ServerConfig>, 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? { match super::try_auth_user(user_opt, auth.as_ref(), &req).await? {
super::LoginReturn::AuthFail(resp) => Ok(resp), super::LoginReturn::AuthFail(resp) => Ok(resp),
super::LoginReturn::Success(user) => { super::LoginReturn::Success(user) => {
@@ -37,6 +95,22 @@ pub async fn dashboard_impl(handlebars_ref: Data<handlebars::Handlebars<'_>>, au
log::debug!("Rendering user's dashboard"); log::debug!("Rendering user's dashboard");
let creation_time = user.creation(); let creation_time = user.creation();
let creation_time_chrono = chrono::DateTime::<chrono::Utc>::from_timestamp_secs(creation_time).unwrap_or_default(); let creation_time_chrono = chrono::DateTime::<chrono::Utc>::from_timestamp_secs(creation_time).unwrap_or_default();
let (garage_stats, factory_stats) = match build_garage_data(user.as_ref(), factory.as_ref()).await {
Ok(x) => x,
Err(e) => return Ok(fallback_render(e, user.as_ref(), handlebars_ref.as_ref(), &req)),
};
let account_stats = match build_account_data(user.as_ref()).await {
Ok(x) => x,
Err(e) => return Ok(fallback_render(e, user.as_ref(), handlebars_ref.as_ref(), &req)),
};
let sanction_stats = match build_sanction_data(user.as_ref()).await {
Ok(x) => x,
Err(e) => return Ok(fallback_render(e, user.as_ref(), handlebars_ref.as_ref(), &req)),
};
let social_stats = match build_social_data(user.as_ref()).await {
Ok(x) => x,
Err(e) => return Ok(fallback_render(e, user.as_ref(), handlebars_ref.as_ref(), &req)),
};
Ok(super::render_ok( Ok(super::render_ok(
RenderData { RenderData {
display_name: user.display_name().to_owned(), display_name: user.display_name().to_owned(),
@@ -45,14 +119,15 @@ pub async fn dashboard_impl(handlebars_ref: Data<handlebars::Handlebars<'_>>, au
user_id: user.account_id(), user_id: user.account_id(),
creation_time_unix: creation_time, creation_time_unix: creation_time,
creation_time_iso: creation_time_chrono.to_rfc3339(), creation_time_iso: creation_time_chrono.to_rfc3339(),
cdn: server_config.cdn_url.clone(),
factory: server_config.factory_url.clone(),
}, },
perms: PermissionData { perms: build_perm_data(user.as_ref()),
r#mod: user.is_mod(), garage: garage_stats,
admin: user.is_admin(), factory: factory_stats,
dev: user.is_dev(), account: account_stats,
royal: user.is_royal(), sanction: sanction_stats,
banned: user.is_banned(), social: social_stats,
}
}, },
handlebars_ref.as_ref(), handlebars_ref.as_ref(),
FORM_NAME, FORM_NAME,
@@ -64,12 +139,177 @@ pub async fn dashboard_impl(handlebars_ref: Data<handlebars::Handlebars<'_>>, au
} }
} }
fn fallback_render(error: Box<dyn std::error::Error>, user: &dyn oj_rc_core::persist::user::WebUser, handlebars_ref: &handlebars::Handlebars<'_>, req: &HttpRequest) -> actix_web::HttpResponse {
let creation_time = user.creation();
let creation_time_chrono = chrono::DateTime::<chrono::Utc>::from_timestamp_secs(creation_time).unwrap_or_default();
super::render_err(
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(),
cdn: String::default(),
factory: String::default(),
},
perms: PermissionData {
r#mod: false,
admin: false,
dev: false,
royal: false,
banned: false,
},
garage: GarageData {
total: 0,
regular_total: 0,
mega_total: 0,
empty_total: 0,
bytes_total: None,
selected_slot: 0,
},
factory: FactoryData {
garages_from_factory: 0,
uploads_total: 0,
},
account: AccountData {
currencies: std::collections::HashMap::default(),
games: 0,
avatar_id: "???".to_owned(),
is_custom_avatar: false,
premium_until_unix: 0,
premium_until_iso: String::default(),
rank: 0,
},
sanction: SanctionData {
total: 0,
pending: 0,
warns: 0,
mutes: 0,
muted_until_unix: None,
muted_until_iso: None,
},
social: SocialData {
clan: None,
friends: 0,
friends_of: 0,
chats: Vec::default()
}
},
format!("Dashboard loading failed: {}", error),
handlebars_ref,
FORM_NAME,
)
.respond_to(req)
.map_into_boxed_body()
}
fn build_perm_data(user: &dyn oj_rc_core::persist::user::WebUser) -> PermissionData {
PermissionData {
r#mod: user.is_mod(),
admin: user.is_admin(),
dev: user.is_dev(),
royal: user.is_royal(),
banned: user.is_banned(),
}
}
async fn build_garage_data(user: &dyn oj_rc_core::persist::user::WebUser, factory: &dyn oj_rc_factory::VehicleFactoryAdapter) -> Result<(GarageData, FactoryData), Box<dyn std::error::Error>> {
let stats = user.garage_stats().await?;
let uploads_count = factory.count(libfj::robocraft::ListQuery {
page: 0,
page_size: 1_000,
order: libfj::robocraft::FactoryOrderType::Added,
player_filter: true,
movement_filter: Vec::default(),
movement_category_filter: Vec::default(),
weapon_filter: Vec::default(),
weapon_category_filter: Vec::default(),
minimum_cpu: 0,
maximum_cpu: i32::MAX as _,
text_filter: user.public_id().to_owned(),
text_search_field: libfj::robocraft::FactoryTextSearchField::Player,
buyable: false,
prepend_featured_robot: false,
featured_only: false,
default_page: false,
}).await?;
Ok((GarageData {
total: stats.vehicle_total,
regular_total: stats.regular_vehicle_total,
mega_total: stats.mega_vehicle_total,
empty_total: stats.empty_vehicle_total,
bytes_total: stats.storage_bytes_total,
selected_slot: stats.selected_garage,
},
FactoryData {
garages_from_factory: stats.factory_vehicle_total,
uploads_total: uploads_count,
}))
}
async fn build_account_data(user: &dyn oj_rc_core::persist::user::WebUser) -> Result<AccountData, Box<dyn std::error::Error>> {
let stats = user.account_stats().await?;
Ok(AccountData {
currencies: stats.currencies.into_iter()
.map(|(key, val)| (currency_to_str(key).to_owned(), val))
.collect(),
games: stats.games_played,
avatar_id: stats.avatar_id.map(|id| format!("#{}", id)).unwrap_or_else(|| "Custom".to_owned()),
is_custom_avatar: stats.avatar_id.is_none(),
premium_until_unix: stats.premium_until,
premium_until_iso: chrono::DateTime::<chrono::Utc>::from_timestamp_secs(stats.premium_until)
.unwrap_or_default()
.to_rfc3339(),
rank: stats.rank,
})
}
fn currency_to_str(currency: oj_rc_core::persist::user::CurrencyType) -> &'static str {
match currency {
oj_rc_core::persist::user::CurrencyType::Free => "robits",
oj_rc_core::persist::user::CurrencyType::Paid => "gc",
oj_rc_core::persist::user::CurrencyType::TechPoints => "tp",
oj_rc_core::persist::user::CurrencyType::Experience => "xp"
}
}
async fn build_sanction_data(user: &dyn oj_rc_core::persist::user::WebUser) -> Result<SanctionData, Box<dyn std::error::Error>> {
let stats = user.sanction_stats().await?;
Ok(SanctionData {
total: stats.total,
pending: stats.pending_total,
warns: stats.warn_total,
mutes: stats.mute_total,
muted_until_unix: stats.muted_until,
muted_until_iso: stats.muted_until
.map(|t| chrono::DateTime::<chrono::Utc>::from_timestamp_secs(t)
.unwrap_or_default()
.to_rfc3339()
),
})
}
async fn build_social_data(user: &dyn oj_rc_core::persist::user::WebUser) -> Result<SocialData, Box<dyn std::error::Error>> {
let stats = user.social_stats().await?;
Ok(SocialData {
clan: stats.clan.map(|clan| ClanData {
name: clan.name,
description: clan.description,
ty: format!("{:?}", clan.ty),
}),
friends: stats.friends_total,
friends_of: stats.friends_of_total,
chats: stats.chats,
})
}
#[get("/dashboard")] #[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 get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, factory: Data<oj_rc_core::factory::Factory>, server_config: Data<oj_rc_core::persist::config::ServerConfig>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
dashboard_impl(handlebars_ref, auth, user_opt, req).await dashboard_impl(handlebars_ref, auth, factory, server_config, user_opt, req).await
} }
#[post("/dashboard")] #[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> { pub async fn post(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Box<oj_rc_core::UserImpl>>, factory: Data<oj_rc_core::factory::Factory>, server_config: Data<oj_rc_core::persist::config::ServerConfig>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
dashboard_impl(handlebars_ref, auth, user_opt, req).await dashboard_impl(handlebars_ref, auth, factory, server_config, user_opt, req).await
} }

View File

@@ -9,6 +9,7 @@ struct RenderData {
is_logged_in: bool, is_logged_in: bool,
display_name: Option<String>, display_name: Option<String>,
server: ServerDetails, server: ServerDetails,
links: LinkDetails,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -16,6 +17,7 @@ struct ServerDetails {
domain: String, domain: String,
cdn: String, cdn: String,
auth: String, auth: String,
factory: String,
min_version: i32, min_version: i32,
server_version: String, server_version: String,
start_time_iso: String, start_time_iso: String,
@@ -32,6 +34,7 @@ fn server_details(conf: &oj_rc_core::persist::config::ServerConfig) -> ServerDet
domain: conf.domain.clone(), domain: conf.domain.clone(),
cdn: conf.cdn_url.clone(), cdn: conf.cdn_url.clone(),
auth: conf.auth_url.clone(), auth: conf.auth_url.clone(),
factory: conf.factory_url.clone(),
min_version: conf.minimum_version, min_version: conf.minimum_version,
server_version, server_version,
start_time_unix, start_time_unix,
@@ -39,9 +42,25 @@ fn server_details(conf: &oj_rc_core::persist::config::ServerConfig) -> ServerDet
} }
} }
#[derive(Serialize, Deserialize)]
struct LinkDetails {
feedback: String,
support: String,
wiki: String,
}
fn links_details(links: &oj_rc_core::persist::config::LinksConfig) -> LinkDetails {
LinkDetails {
feedback: links.feedback_url.clone(),
support: links.support_url.clone(),
wiki: links.wiki_url.clone(),
}
}
#[get("/")] #[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> { 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>, server_links: Data<oj_rc_core::persist::config::LinksConfig>, user_opt: Option<Identity>, req: HttpRequest) -> Result<impl Responder, actix_web::error::Error> {
let server_info = server_details(server_config.as_ref()); let server_info = server_details(server_config.as_ref());
let links_info = links_details(server_links.as_ref());
if let Some(user) = user_opt { if let Some(user) = user_opt {
match super::try_auth_user(Some(user), auth.as_ref(), &req).await? { match super::try_auth_user(Some(user), auth.as_ref(), &req).await? {
super::LoginReturn::AuthFail(resp) => Ok(resp), super::LoginReturn::AuthFail(resp) => Ok(resp),
@@ -51,6 +70,7 @@ pub async fn get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Bo
is_logged_in: true, is_logged_in: true,
display_name: Some(user.display_name().to_owned()), display_name: Some(user.display_name().to_owned()),
server: server_info, server: server_info,
links: links_info,
}, },
handlebars_ref.as_ref(), handlebars_ref.as_ref(),
FORM_NAME, FORM_NAME,
@@ -66,6 +86,7 @@ pub async fn get(handlebars_ref: Data<handlebars::Handlebars<'_>>, auth: Data<Bo
is_logged_in: false, is_logged_in: false,
display_name: None, display_name: None,
server: server_info, server: server_info,
links: links_info,
}, },
handlebars_ref.as_ref(), handlebars_ref.as_ref(),
FORM_NAME, FORM_NAME,