1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-05-06 04:37:46 +00:00

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.
This commit is contained in:
Joshua Goins 2025-05-01 15:34:01 -04:00
parent ed8ccb86ee
commit afed151488
9 changed files with 113 additions and 20 deletions

33
Cargo.lock generated
View file

@ -57,6 +57,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@ -321,6 +322,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 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]] [[package]]
name = "env_home" name = "env_home"
version = "0.1.0" version = "0.1.0"
@ -937,6 +947,23 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@ -1335,6 +1362,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.0" version = "1.2.0"

View file

@ -52,7 +52,7 @@ serde_json = { version = "1.0", features = ["std"], default-features = false }
[dependencies] [dependencies]
# Used for the web servers # 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 } axum-extra = { version = "0.10", features = ["cookie"], default-features = false }
# Serialization used in almost every server # Serialization used in almost every server

View file

@ -74,7 +74,7 @@ Some other launchers (like XIVLauncher) will allow you to specify these extra ar
## Importing characters from retail ## 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. This feature is still a work-in-progress, and not all data is imported yet.

View file

@ -1,12 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::{Query, State}; use axum::extract::{Multipart, Query, State};
use axum::response::{Html, Redirect}; use axum::response::{Html, Redirect};
use axum::routing::post; use axum::routing::post;
use axum::{Form, Router, routing::get}; use axum::{Form, Router, routing::get};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::{Cookie, Expiration}; use axum_extra::extract::cookie::{Cookie, Expiration};
use kawari::common::custom_ipc::{CustomIpcData, CustomIpcSegment, CustomIpcType};
use kawari::config::get_config; use kawari::config::get_config;
use kawari::lobby::send_custom_world_packet;
use kawari::login::{LoginDatabase, LoginError}; use kawari::login::{LoginDatabase, LoginError};
use minijinja::{Environment, context}; use minijinja::{Environment, context};
use serde::Deserialize; use serde::Deserialize;
@ -189,6 +191,42 @@ async fn account(State(state): State<LoginServerState>, jar: CookieJar) -> Html<
} }
} }
async fn upload_character_backup(
State(state): State<LoginServerState>,
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) { async fn logout(jar: CookieJar) -> (CookieJar, Redirect) {
let config = get_config(); let config = get_config();
// TODO: remove session from database // TODO: remove session from database
@ -231,6 +269,7 @@ async fn main() {
.route("/oauth/oa/registligt", get(register)) .route("/oauth/oa/registligt", get(register))
.route("/oauth/oa/registlist", post(do_register)) .route("/oauth/oa/registlist", post(do_register))
.route("/account/app/svc/manage", get(account)) .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/logout", get(logout))
.route("/account/app/svc/mbrPasswd", get(change_password)) .route("/account/app/svc/mbrPasswd", get(change_password))
.route("/account/app/svc/mbrCancel", get(cancel_account)) .route("/account/app/svc/mbrCancel", get(cancel_account))

View file

@ -1005,6 +1005,9 @@ async fn client_loop(
.await; .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!") panic!("The server is recieving a response or unknown custom IPC!")
} }

View file

@ -24,6 +24,7 @@ impl ReadWriteIpcSegment for CustomIpcSegment {
CustomIpcType::RequestCharacterListRepsonse => 1 + (1184 * 8), CustomIpcType::RequestCharacterListRepsonse => 1 + (1184 * 8),
CustomIpcType::DeleteCharacter => 4, CustomIpcType::DeleteCharacter => 4,
CustomIpcType::CharacterDeleted => 1, CustomIpcType::CharacterDeleted => 1,
CustomIpcType::ImportCharacter => 132,
} }
} }
} }
@ -53,6 +54,8 @@ pub enum CustomIpcType {
DeleteCharacter = 0x9, DeleteCharacter = 0x9,
/// Response to DeleteCharacter /// Response to DeleteCharacter
CharacterDeleted = 0x10, CharacterDeleted = 0x10,
/// Request to import a character backup
ImportCharacter = 0x11,
} }
#[binrw] #[binrw]
@ -107,6 +110,15 @@ pub enum CustomIpcData {
DeleteCharacter { content_id: u64 }, DeleteCharacter { content_id: u64 },
#[br(pre_assert(*magic == CustomIpcType::CharacterDeleted))] #[br(pre_assert(*magic == CustomIpcType::CharacterDeleted))]
CharacterDeleted { deleted: u8 }, 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 { impl Default for CustomIpcData {

View file

@ -209,4 +209,15 @@ impl LoginDatabase {
.unwrap(); .unwrap();
stmt.query_row((user_id,), |row| row.get(0)).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()
}
} }

View file

@ -51,24 +51,11 @@ impl WorldDatabase {
connection: Mutex::new(connection), 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 this
} }
fn import_character(&self, path: &str) { pub fn import_character(&self, service_account_id: u32, path: &str) {
tracing::info!("Importing character backup {path}..."); tracing::info!("Importing character backup from {path}...");
let file = std::fs::File::open(path).unwrap(); let file = std::fs::File::open(path).unwrap();
@ -105,7 +92,8 @@ impl WorldDatabase {
} }
if !self.check_is_name_free(&character.name) { 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; return;
} }
@ -127,7 +115,7 @@ impl WorldDatabase {
// TODO: import inventory // TODO: import inventory
self.create_player_data( self.create_player_data(
0x1, service_account_id,
&character.name, &character.name,
&chara_make.to_json(), &chara_make.to_json(),
character.city_state.value as u8, character.city_state.value as u8,

View file

@ -2,3 +2,10 @@
<a href="/account/app/svc/logout">Logout</a> <a href="/account/app/svc/logout">Logout</a>
<a href="/account/app/svc/mbrPasswd">Change Password</a> <a href="/account/app/svc/mbrPasswd">Change Password</a>
<a href="/account/app/svc/mbrCancel">Cancel Account</a> <a href="/account/app/svc/mbrCancel">Cancel Account</a>
<!-- TODO: Move to it's own page. -->
<form method='post' enctype="multipart/form-data">
<label for="charbak">Upload character backup:</label>
<input type="file" id="charbak" name="charbak" accept="application/zip" />
<button type='submit'>Upload</button>
</form>