From afed1514887ae4ed0114e82f11c2db658242f831 Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Thu, 1 May 2025 15:34:01 -0400 Subject: [PATCH] Add "character backup import" feature to the account management page We have had an import feature for a while, allowing you to easily recreate your retail character from Auracite backups. But the feature was implemented *before* we had proper service accounts, and it always assigned it to ID 1. Now it's moved to the user-visible account management page. --- Cargo.lock | 33 ++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- USAGE.md | 2 +- src/bin/kawari-login.rs | 41 +++++++++++++++++++++++++++++++++++++++- src/bin/kawari-world.rs | 3 +++ src/common/custom_ipc.rs | 12 ++++++++++++ src/login/database.rs | 11 +++++++++++ src/world/database.rs | 22 +++++---------------- templates/account.html | 7 +++++++ 9 files changed, 113 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31e6c2a..07bbf8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -321,6 +322,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_home" version = "0.1.0" @@ -937,6 +947,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1335,6 +1362,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 15567dc..e715030 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ serde_json = { version = "1.0", features = ["std"], default-features = false } [dependencies] # Used for the web servers -axum = { version = "0.8", features = ["json", "tokio", "http1", "form", "query"], default-features = false } +axum = { version = "0.8", features = ["json", "tokio", "http1", "form", "query", "multipart"], default-features = false } axum-extra = { version = "0.10", features = ["cookie"], default-features = false } # Serialization used in almost every server diff --git a/USAGE.md b/USAGE.md index 457d9d6..7e67c40 100644 --- a/USAGE.md +++ b/USAGE.md @@ -74,7 +74,7 @@ Some other launchers (like XIVLauncher) will allow you to specify these extra ar ## Importing characters from retail -It's possible to import existing characters from the retail server using [Auracite](https://auracite.xiv.zone). Place the backup ZIP under `backups/` and the World server will import it into the database the next time it's started. +It's possible to import existing characters from the retail server using [Auracite](https://auracite.xiv.zone). Upload the backup ZIP on the account management page on the login server. This feature is still a work-in-progress, and not all data is imported yet. diff --git a/src/bin/kawari-login.rs b/src/bin/kawari-login.rs index 8694054..e18ac78 100644 --- a/src/bin/kawari-login.rs +++ b/src/bin/kawari-login.rs @@ -1,12 +1,14 @@ use std::sync::Arc; -use axum::extract::{Query, State}; +use axum::extract::{Multipart, Query, State}; use axum::response::{Html, Redirect}; use axum::routing::post; use axum::{Form, Router, routing::get}; use axum_extra::extract::CookieJar; use axum_extra::extract::cookie::{Cookie, Expiration}; +use kawari::common::custom_ipc::{CustomIpcData, CustomIpcSegment, CustomIpcType}; use kawari::config::get_config; +use kawari::lobby::send_custom_world_packet; use kawari::login::{LoginDatabase, LoginError}; use minijinja::{Environment, context}; use serde::Deserialize; @@ -189,6 +191,42 @@ async fn account(State(state): State, jar: CookieJar) -> Html< } } +async fn upload_character_backup( + State(state): State, + jar: CookieJar, + mut multipart: Multipart, +) -> Redirect { + if let Some(session_id) = jar.get("cis_sessid") { + let user_id = state.database.get_user_id(session_id.value()); + let service_account_id = state.database.get_service_account(user_id); + + while let Some(field) = multipart.next_field().await.unwrap() { + let name = field.name().unwrap().to_string(); + let data = field.bytes().await.unwrap(); + + std::fs::write("temp.zip", data).unwrap(); + + if name == "charbak" { + let ipc_segment = CustomIpcSegment { + unk1: 0, + unk2: 0, + op_code: CustomIpcType::ImportCharacter, + server_id: 0, + timestamp: 0, + data: CustomIpcData::ImportCharacter { + service_account_id, + path: "temp.zip".to_string(), + }, + }; + + send_custom_world_packet(ipc_segment).await.unwrap(); + } + } + } + + Redirect::to("/account/app/svc/manage") +} + async fn logout(jar: CookieJar) -> (CookieJar, Redirect) { let config = get_config(); // TODO: remove session from database @@ -231,6 +269,7 @@ async fn main() { .route("/oauth/oa/registligt", get(register)) .route("/oauth/oa/registlist", post(do_register)) .route("/account/app/svc/manage", get(account)) + .route("/account/app/svc/manage", post(upload_character_backup)) .route("/account/app/svc/logout", get(logout)) .route("/account/app/svc/mbrPasswd", get(change_password)) .route("/account/app/svc/mbrCancel", get(cancel_account)) diff --git a/src/bin/kawari-world.rs b/src/bin/kawari-world.rs index c35d230..781161c 100644 --- a/src/bin/kawari-world.rs +++ b/src/bin/kawari-world.rs @@ -1005,6 +1005,9 @@ async fn client_loop( .await; } } + CustomIpcData::ImportCharacter { service_account_id, path } => { + database.import_character(*service_account_id, path); + } _ => { panic!("The server is recieving a response or unknown custom IPC!") } diff --git a/src/common/custom_ipc.rs b/src/common/custom_ipc.rs index a407c21..f94880e 100644 --- a/src/common/custom_ipc.rs +++ b/src/common/custom_ipc.rs @@ -24,6 +24,7 @@ impl ReadWriteIpcSegment for CustomIpcSegment { CustomIpcType::RequestCharacterListRepsonse => 1 + (1184 * 8), CustomIpcType::DeleteCharacter => 4, CustomIpcType::CharacterDeleted => 1, + CustomIpcType::ImportCharacter => 132, } } } @@ -53,6 +54,8 @@ pub enum CustomIpcType { DeleteCharacter = 0x9, /// Response to DeleteCharacter CharacterDeleted = 0x10, + /// Request to import a character backup + ImportCharacter = 0x11, } #[binrw] @@ -107,6 +110,15 @@ pub enum CustomIpcData { DeleteCharacter { content_id: u64 }, #[br(pre_assert(*magic == CustomIpcType::CharacterDeleted))] CharacterDeleted { deleted: u8 }, + #[br(pre_assert(*magic == CustomIpcType::ImportCharacter))] + ImportCharacter { + service_account_id: u32, + #[bw(pad_size_to = 128)] + #[br(count = 128)] + #[br(map = read_string)] + #[bw(map = write_string)] + path: String, + }, } impl Default for CustomIpcData { diff --git a/src/login/database.rs b/src/login/database.rs index 6c25e75..c8ae9a9 100644 --- a/src/login/database.rs +++ b/src/login/database.rs @@ -209,4 +209,15 @@ impl LoginDatabase { .unwrap(); stmt.query_row((user_id,), |row| row.get(0)).unwrap() } + + /// TODO: only works for one + pub fn get_service_account(&self, user_id: u32) -> u32 { + let connection = self.connection.lock().unwrap(); + + let mut stmt = connection + .prepare("SELECT id FROM service_accounts WHERE user_id = ?1") + .ok() + .unwrap(); + stmt.query_row((user_id,), |row| row.get(0)).unwrap() + } } diff --git a/src/world/database.rs b/src/world/database.rs index 520084c..c55c6b5 100644 --- a/src/world/database.rs +++ b/src/world/database.rs @@ -51,24 +51,11 @@ impl WorldDatabase { connection: Mutex::new(connection), }; - // Import any backups - // NOTE: This won't make sense when service accounts are a real thing, so the functionality will probably be moved - { - if let Ok(paths) = std::fs::read_dir("./backups") { - for path in paths { - let path = path.unwrap().path(); - if path.extension().unwrap() == "zip" { - this.import_character(path.to_str().unwrap()); - } - } - } - } - this } - fn import_character(&self, path: &str) { - tracing::info!("Importing character backup {path}..."); + pub fn import_character(&self, service_account_id: u32, path: &str) { + tracing::info!("Importing character backup from {path}..."); let file = std::fs::File::open(path).unwrap(); @@ -105,7 +92,8 @@ impl WorldDatabase { } if !self.check_is_name_free(&character.name) { - tracing::warn!("* Skipping since this character already exists."); + let name = character.name; + tracing::warn!("* Skipping since {name} already exists."); return; } @@ -127,7 +115,7 @@ impl WorldDatabase { // TODO: import inventory self.create_player_data( - 0x1, + service_account_id, &character.name, &chara_make.to_json(), character.city_state.value as u8, diff --git a/templates/account.html b/templates/account.html index bb11d59..a982b18 100644 --- a/templates/account.html +++ b/templates/account.html @@ -2,3 +2,10 @@ Logout Change Password Cancel Account + + +
+ + + +