5 Commits
19 changed files with 738 additions and 101201 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ services:
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
#PURGE: false # Purge dead user accounts on startup
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
#MASTERDATA: /masterdata/ # Override bundled asset lists / CSVs / new_user.json at runtime (missing files fall back to the internal copies)
#MASTERDATA: /masterdata/ # Override bundled CSVs / new_user.json at runtime (missing files fall back to the internal copies)
# Asset hash / version overrides. Leave unset to use the values bundled in
# the binary; set one to make the server report a different hash/version
+31 -3
View File
@@ -60,9 +60,35 @@ fn vacuum_database() {
DATABASE.lock_and_exec("VACUUM", params!());
}
pub fn get_user_cert(uuid: &str) -> Option<(i64, String)> {
let user_id = DATABASE.lock_and_select("SELECT user_id FROM users WHERE uuid=?1;", params!(uuid)).ok()?.parse::<i64>().ok()?;
let cert = DATABASE.lock_and_select("SELECT cert FROM users WHERE uuid=?1;", params!(uuid)).ok()?;
Some((user_id, cert))
}
pub fn is_registered(uuid: &str) -> bool {
match get_user_cert(uuid) {
Some((_, cert)) => pem::parse(&cert).is_ok(),
None => false
}
}
pub fn verify_fingerprint(uuid: &str, fingerprint: &str, cert: &str) -> bool {
let Ok(signature) = general_purpose::STANDARD.decode(fingerprint) else {
return false;
};
verify_signature(&signature, uuid.as_bytes(), cert)
}
fn verify_signature(signature: &[u8], message: &[u8], public_key: &str) -> bool {
let pem = pem::parse(public_key).unwrap();
let public_key = RsaPublicKey::from_public_key_der(&pem.contents()).unwrap();
let Ok(pem) = pem::parse(public_key) else {
return false;
};
let Ok(public_key) = RsaPublicKey::from_public_key_der(&pem.contents()) else {
return false;
};
let digest = Sha1::digest(message);
public_key
@@ -80,7 +106,9 @@ pub fn get_uuid(headers: &HeaderMap, body: &str) -> String {
return String::new();
}
let cert = DATABASE.lock_and_select("SELECT cert FROM users WHERE user_id=?1;", params!(uid)).unwrap();
let Ok(cert) = DATABASE.lock_and_select("SELECT cert FROM users WHERE user_id=?1;", params!(uid)) else {
return String::new();
};
let data = format!("{}{}{}{}{}", uid, "sk1bdzb310n0s9tl", version, timestamp, body);
let encoded = general_purpose::STANDARD.encode(data.as_bytes());
+1 -1
View File
@@ -71,7 +71,7 @@ pub struct Args {
#[arg(long, default_value = "", help = "Path to image assets.")]
pub image_asset_path: String,
#[arg(long, default_value = "", help = "Optional directory to load asset lists and master data CSVs from at runtime. Layout mirrors the bundled assets (asset_lists/, csv/, csv-en/). Missing files fall back to the internal copies.")]
#[arg(long, default_value = "", help = "Optional directory to load master data CSVs from at runtime. Layout mirrors the bundled assets (csv/, csv-en/). Missing files fall back to the internal copies.")]
pub masterdata: String
}
+33 -7
View File
@@ -28,8 +28,6 @@ pub mod items;
pub mod databases;
pub mod location;
pub mod event_ranking;
pub mod asset_lists;
mod master_data;
mod tools;
use actix_web::{
@@ -50,6 +48,27 @@ pub struct Body(pub JsonValue);
pub struct Login(pub String);
struct SessionError(HttpRequest);
impl std::fmt::Debug for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "invalid session for uid {}", global::get_uid(self.0.headers()))
}
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl actix_web::ResponseError for SessionError {
fn error_response(&self) -> HttpResponse {
println!("Rejecting request from uid {}: bad session", global::get_uid(self.0.headers()));
global::api_error(&self.0, global::RESULT_SESSION)
}
}
pub struct Session {
pub key: String,
pub body: JsonValue,
@@ -93,9 +112,14 @@ impl FromRequest for Login {
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let headers = req.headers().clone();
let fut = String::from_request(req, payload);
let req = req.clone();
let fut = String::from_request(&req, payload);
async move {
Ok(Login(global::get_login(&headers, &encryption::decrypt_packet(&fut.await?).unwrap())))
let key = global::get_login(&headers, &encryption::decrypt_packet(&fut.await?).unwrap());
if key.is_empty() {
return Err(SessionError(req).into());
}
Ok(Login(key))
}.boxed_local()
}
}
@@ -106,10 +130,14 @@ impl FromRequest for Session {
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let headers = req.headers().clone();
let fut = String::from_request(req, payload);
let req = req.clone();
let fut = String::from_request(&req, payload);
async move {
let body = encryption::decrypt_packet(&fut.await?).unwrap();
let key = global::get_login(&headers, &body);
if key.is_empty() {
return Err(SessionError(req).into());
}
Ok(Session { key, body: jzon::parse(&body).unwrap() })
}.boxed_local()
}
@@ -212,8 +240,6 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
cfg.configure(crate::static_handlers::routes);
cfg.service(
actix_web::web::scope("/api")
.configure(asset_lists::routes)
.configure(master_data::routes)
.service(
actix_web::web::scope("")
.wrap(from_fn(webui_fallback))
-54
View File
@@ -1,54 +0,0 @@
use actix_web::{HttpRequest, web, Responder, HttpResponse};
use actix_web::http::header::ContentType;
use jzon::object;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::Mutex;
use crate::include_file;
lazy_static! {
static ref LIST_CACHE: Mutex<HashMap<String, String>> = Mutex::new(HashMap::new());
}
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/assetLists")
.route("/supported", web::get().to(supported))
.route("{platform}/{LANG}", web::get().to(get))
);
}
async fn get(_req: HttpRequest) -> impl Responder {
let mut response = object!{};
response["Bundle"] = load_list("Bundle").into();
response["Movie"] = load_list("Movie").into();
response["Sound"] = load_list("Sound").into();
let body = jzon::stringify(response);
HttpResponse::Ok()
.insert_header(("content-type", ContentType::json()))
.insert_header(("content-length", body.len()))
.body(body)
}
fn load_list(name: &str) -> String {
if let Some(cached) = LIST_CACHE.lock().unwrap().get(name) {
return cached.clone();
}
let rel = format!("asset_lists/{}.json", name);
let list = crate::runtime::read_masterdata_file(&rel)
.and_then(|b| String::from_utf8(b).ok())
.unwrap_or_else(|| match name {
"Bundle" => include_file!("src/router/asset_lists/Bundle.json"),
"Movie" => include_file!("src/router/asset_lists/Movie.json"),
"Sound" => include_file!("src/router/asset_lists/Sound.json"),
_ => unreachable!(),
});
LIST_CACHE.lock().unwrap().insert(name.to_string(), list.clone());
list
}
async fn supported() -> impl Responder {
"SUPPORTED"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -13,17 +13,23 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
}
// Custom cards live at id prefix 10000+ (imported 10000-14999, new 15000+).
// Like custom songs they need a protocol version (2, global::PROTOCOL_HEADER):
// Like custom songs they need a protocol version (global::PROTOCOL_HEADER):
// clients below it can't resolve the ids
pub const PROTOCOL_VERSION: u32 = 2;
pub fn is_custom(master_card_id: i64) -> bool {
master_card_id >= 100_000_000
}
pub fn client_supports_custom_cards(req: &HttpRequest) -> bool {
global::client_protocol_version(req) >= 2
global::client_protocol_version(req) >= PROTOCOL_VERSION
}
pub fn owns_custom(user: &JsonValue) -> bool {
pub fn account_supports_custom_cards(auth_key: &str) -> bool {
userdata::get_protocol_version(auth_key) >= PROTOCOL_VERSION
}
pub fn account_has_custom_cards(user: &JsonValue) -> bool {
user["card_list"].members().any(|card| is_custom(card["master_card_id"].as_i64().unwrap_or(0)))
}
+8 -1
View File
@@ -268,7 +268,14 @@ pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl R
let uid = data["user"].as_i64().unwrap();
let user = guest::get_user(uid, &object![], guest::UserView::Ranking, custom_cards);
let user_obj = if uid == self_id {
userdata::get_acc_from_uid(uid)["user"].clone()
// The client wants the fields get_user hides from other players
let mut self_user = object!{
user: userdata::get_acc_from_uid(uid)["user"].clone()
};
if !custom_cards {
guest::proxy_user_cards(&mut self_user);
}
self_user["user"].clone()
} else {
user["user"].clone()
};
-21
View File
@@ -40,27 +40,6 @@ fn region_subdir(region: Region) -> &'static str {
}
}
pub fn get_all(region: Region) -> JsonValue {
let mut rv = object!{};
for file in dir_for(region).files() {
let table_name = file.path()
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
if table_name.is_empty() {
continue;
}
if let Some(bytes) = csv_bytes(region, table_name) {
rv[table_name] = String::from_utf8(bytes).unwrap_or_default().into();
}
}
rv
}
pub fn csv_bytes(region: Region, name: &str) -> Option<Vec<u8>> {
let rel = format!("{}/{}.csv", region_subdir(region), name);
if let Some(bytes) = crate::runtime::read_masterdata_file(&rel) {
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -118,6 +118,8 @@ pub const RESULT_GAME_VERSION_UPDATED: i32 = 12;
pub const RESULT_RESOURCE_UPDATED: i32 = 13;
pub const RESULT_SESSION: i32 = 14;
pub const PROTOCOL_HEADER: &str = "X-Protocol-Version";
pub fn client_protocol_version(req: &HttpRequest) -> u32 {
@@ -230,11 +232,7 @@ pub fn get_login(headers: &HeaderMap, body: &str) -> String {
Some(token) => {
token
},
None => {
let rv = gree::get_uuid(headers, body);
assert!(rv != String::new());
rv
},
None => gree::get_uuid(headers, body),
}
}
+47 -4
View File
@@ -16,6 +16,8 @@ use crate::database::gree::*;
const APP_ID: &str = "232610769078541";
const SRC_APP_ID: &str = "100900301";
const HMAC_SECRET_HEX: &str = "6438663638653238346566646636306262616563326432323563306366643432";
const ERROR_INVALID_SIGNATURE: i32 = 4;
const ERROR_MIGRATED_DEVICE: i32 = 20;
struct RequireGreeAuth;
@@ -51,6 +53,8 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
.service(
web::scope("/migration")
.route("", web::post().to(migration))
.route("/auth/initialize", web::post().to(initialize))
.route("/user/token", web::post().to(migration_user_token))
.route("/code", web::get().to(migration_code))
.route("/code/verify", web::post().to(migration_verify))
.route("/password/register", web::post().to(migration_password_register))
@@ -84,7 +88,7 @@ fn get_uid(req: &HttpRequest) -> String {
req.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|auth| auth.split(",xoauth_requestor_id=\"").nth(1))
.and_then(|auth| auth.split("xoauth_requestor_id=\"").nth(1))
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string()
@@ -95,13 +99,13 @@ fn send(req: HttpRequest, resp: JsonValue) -> impl Responder {
jzon::stringify(resp)
}
async fn not_found() -> impl Responder {
async fn not_found(req: HttpRequest) -> impl Responder {
let resp = object!{
code: 10001,
message: "Not Found",
result: "NG"
};
jzon::stringify(resp)
send(req, resp)
}
async fn initialize(req: HttpRequest, body: String) -> impl Responder {
@@ -119,8 +123,17 @@ async fn initialize(req: HttpRequest, body: String) -> impl Responder {
}
async fn authorize(req: HttpRequest, _body: String) -> impl Responder {
let resp = object!{
let resp = if is_registered(&get_uid(&req)) {
object!{
result: "OK"
}
} else {
println!("Unregistered device authorizing: {}", get_uid(&req));
object!{
result: "NG",
code: ERROR_MIGRATED_DEVICE,
message: "Device is not registered"
}
};
send(req, resp)
@@ -224,6 +237,36 @@ async fn migration(req: HttpRequest, body: String) -> impl Responder {
send(req, resp)
}
async fn migration_user_token(req: HttpRequest, body: String) -> impl Responder {
let body = jzon::parse(&body).unwrap_or(object!{});
let uuid = body["uuid"].to_string();
let resp = if let Some((user_id, cert)) = get_user_cert(&uuid) {
if verify_fingerprint(&uuid, &body["fingerprint"].to_string(), &cert) {
update_cert(user_id, &body["token"].to_string());
object!{
result: "OK"
}
} else {
println!("Rejecting device token update for {}: bad fingerprint", uuid);
object!{
result: "NG",
code: ERROR_INVALID_SIGNATURE,
message: "Invalid fingerprint"
}
}
} else {
println!("Rejecting device token update for unregistered device {}", uuid);
object!{
result: "NG",
code: ERROR_MIGRATED_DEVICE,
message: "Device is not registered"
}
};
send(req, resp)
}
async fn balance(req: HttpRequest) -> impl Responder {
let uid = get_uid(&req);
-31
View File
@@ -1,31 +0,0 @@
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use actix_web::http::header::ContentType;
use crate::router::databases::csv::{get_all, Region};
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/masterdata")
.route("/supported", web::get().to(supported))
.route("/{platform}/{LANG}", web::get().to(mst))
);
}
async fn mst(req: HttpRequest) -> impl Responder {
let lang = req.match_info().get("LANG").unwrap_or("JP");
let region = match lang.to_ascii_uppercase().as_str() {
"JP" => Region::Jp,
_ => Region::En, // idk
};
let body = get_all(region);
let body = jzon::stringify(body);
HttpResponse::Ok()
.insert_header(("content-type", ContentType::json()))
.insert_header(("content-length", body.len()))
.body(body)
}
async fn supported() -> impl Responder {
"SUPPORTED"
}
+5 -2
View File
@@ -25,7 +25,6 @@ fn get_asset_hash(req: &HttpRequest, body: &JsonValue) -> Option<String> {
}
async fn asset_hash(req: HttpRequest, Body(body): Body) -> impl Responder {
match get_asset_hash(&req, &body) {
Some(hash) => global::api(&req, Some(object!{
"asset_hash": hash
@@ -35,7 +34,6 @@ async fn asset_hash(req: HttpRequest, Body(body): Body) -> impl Responder {
}
async fn start(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
let Some(asset_hash) = get_asset_hash(&req, &body) else {
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
};
@@ -47,6 +45,11 @@ async fn start(req: HttpRequest, Session { key, body }: Session) -> impl Respond
user["user"]["last_login_time"] = global::timestamp().into();
userdata::save_acc(&key, user);
userdata::save_protocol_version(&key, global::client_protocol_version(&req));
if !crate::router::card::client_supports_custom_cards(&req) && crate::router::card::account_supports_custom_cards(&key) {
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); // todo - maybe compatibility layer?
}
global::api(&req, Some(object!{
"asset_hash": asset_hash,
+36 -9
View File
@@ -65,15 +65,23 @@ pub enum UserView {
Ranking,
}
const DEFAULT_CARD: i64 = 10010001;
fn proxy_card_id(id: i64) -> i64 {
let prefix = id / 10000;
if prefix < 10000 {
id
} else if prefix < 14000 {
return id;
}
let rv = if prefix < 14000 {
(prefix - 9000) * 10000 + 1
} else {
10010001
DEFAULT_CARD
};
// Not every prefix has a real character behind it
if crate::router::databases::CARD_LIST[rv.to_string()].is_empty() {
return DEFAULT_CARD;
}
rv
}
pub fn proxy_user_cards(user: &mut JsonValue) {
@@ -83,25 +91,44 @@ pub fn proxy_user_cards(user: &mut JsonValue) {
user["user"][key] = proxy_card_id(id).into();
}
}
// A card's id is its master_card_id, so both sides need proxying
for key in ["favorite_card", "guest_smile_card", "guest_cool_card", "guest_pure_card"] {
let id = user[key]["master_card_id"].as_i64().unwrap_or(0);
if crate::router::card::is_custom(id) {
user[key]["id"] = proxy_card_id(id).into();
user[key]["master_card_id"] = proxy_card_id(id).into();
}
}
if !user["main_deck_detail"].is_empty() {
let mut used = array![];
for id in user["main_deck_detail"]["deck"]["main_card_ids"].members_mut() {
let card = id.as_i64().unwrap_or(0);
if crate::router::card::is_custom(card) {
*id = proxy_card_id(card).into();
let card = proxy_card_id(id.as_i64().unwrap_or(0));
// Whole characters share one proxy, and the client can't hold the
// same card twice
if card == 0 || used.contains(card) {
*id = (0).into();
continue;
}
used.push(card).unwrap();
*id = card.into();
}
for card in user["main_deck_detail"]["card_list"].members_mut() {
let mut cards = array![];
let mut ids = array![];
for card in user["main_deck_detail"]["card_list"].members() {
let id = card["master_card_id"].as_i64().unwrap_or(0);
if crate::router::card::is_custom(id) {
card["master_card_id"] = proxy_card_id(id).into();
let proxy = proxy_card_id(id);
if ids.contains(proxy) {
continue;
}
ids.push(proxy).unwrap();
let mut card = card.clone();
if proxy != id {
card["id"] = proxy.into();
card["master_card_id"] = proxy.into();
}
cards.push(card).unwrap();
}
user["main_deck_detail"]["card_list"] = cards;
}
}
-5
View File
@@ -59,11 +59,6 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc(&key);
// An account holding custom cards would break clients below protocol 2
if !crate::router::card::client_supports_custom_cards(&req) && crate::router::card::owns_custom(&user) {
return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED);
}
user["lottery_list"] = array![];
if crate::router::custom_song::client_supports_custom_songs(&req) {
+68 -3
View File
@@ -7,6 +7,7 @@ use rand::RngExt;
use crate::router::global;
use crate::router::items;
use crate::router::card;
use crate::database::custom_song;
use crate::sql::SQLite;
use crate::include_file;
@@ -35,7 +36,8 @@ CREATE TABLE IF NOT EXISTS tokens (
CREATE TABLE IF NOT EXISTS userdata (
user_id BIGINT NOT NULL PRIMARY KEY,
userdata TEXT NOT NULL,
friend_request_disabled INT NOT NULL
friend_request_disabled INT NOT NULL,
protocol_version INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS userhome (
user_id BIGINT NOT NULL PRIMARY KEY,
@@ -84,8 +86,63 @@ CREATE TABLE IF NOT EXISTS webui (
);
INSERT OR IGNORE INTO exchange (user_id, exchange) SELECT user_id, '[]' FROM userdata;
").unwrap();
let is_updated = conn.prepare("SELECT protocol_version FROM userdata LIMIT 1;").is_ok();
if !is_updated {
println!("Upgrading userdata table");
conn.execute("ALTER TABLE userdata ADD COLUMN protocol_version INT NOT NULL DEFAULT 0;", []).unwrap();
}
}
// maybe we will use this later
/*
pub fn downgrade_account_cards(user: &JsonValue) -> JsonValue {
let mut rv = user.clone();
let mut cards = array![];
let mut ids = array![];
for data in user["card_list"].members() {
let id = data["master_card_id"].as_i64().unwrap_or(0);
let downgraded = guest::proxy_card_id(id);
// Whole characters share one downgrade, and the client can't hold the
// same card twice
if ids.contains(downgraded) {
continue;
}
ids.push(downgraded).unwrap();
let mut data = data.clone();
if downgraded != id {
data["id"] = downgraded.into();
data["master_card_id"] = downgraded.into();
}
cards.push(data).unwrap();
}
rv["card_list"] = cards;
for deck in rv["deck_list"].members_mut() {
let mut used = array![];
for slot in deck["main_card_ids"].members_mut() {
let id = guest::proxy_card_id(slot.as_i64().unwrap_or(0));
// Cards the downgrade merged away leave the slot empty
if id == 0 || used.contains(id) {
*slot = (0).into();
continue;
}
used.push(id).unwrap();
*slot = id.into();
}
}
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
let id = rv["user"][key].as_i64().unwrap_or(0);
rv["user"][key] = guest::proxy_card_id(id).into();
}
rv
}
*/
fn acc_exists(uid: i64) -> bool {
DATABASE.lock_and_select("SELECT user_id FROM userdata WHERE user_id=?1", params!(uid)).is_ok()
}
@@ -120,10 +177,11 @@ fn add_user_to_database(uid: i64, user: JsonValue, user_home: JsonValue, user_mi
let missions = jzon::stringify(user_missions.clone());
let cards = jzon::stringify(sif_cards.clone());
DATABASE.lock_and_exec("INSERT INTO userdata (user_id, userdata, friend_request_disabled) VALUES (?1, ?2, ?3)", params!(
DATABASE.lock_and_exec("INSERT INTO userdata (user_id, userdata, friend_request_disabled, protocol_version) VALUES (?1, ?2, ?3, ?4)", params!(
uid,
jzon::stringify(user.clone()),
user["user"]["friend_request_disabled"].as_i32().unwrap()
user["user"]["friend_request_disabled"].as_i32().unwrap(),
if card::account_has_custom_cards(&user) { card::PROTOCOL_VERSION } else { 0 }
));
DATABASE.lock_and_exec("INSERT INTO userhome (user_id, userhome) VALUES (?1, ?2)", params!(
uid,
@@ -325,6 +383,9 @@ pub fn get_acc_event(auth_key: &str) -> JsonValue {
pub fn get_acc_eventlogin(auth_key: &str) -> JsonValue {
get_data(auth_key, "eventloginbonus")
}
pub fn get_protocol_version(auth_key: &str) -> u32 {
DATABASE.lock_and_select("SELECT protocol_version FROM userdata WHERE user_id=?1", params!(get_key(auth_key))).unwrap_or_default().parse::<u32>().unwrap_or(0)
}
pub fn save_data(auth_key: &str, row: &str, data: JsonValue) {
let key = get_key(auth_key);
@@ -363,6 +424,10 @@ pub fn save_acc_chats(auth_key: &str, data: JsonValue) {
pub fn save_acc_exchange(auth_key: &str, data: JsonValue) {
save_data(auth_key, "exchange", data);
}
pub fn save_protocol_version(auth_key: &str, version: u32) {
DATABASE.lock_and_exec("UPDATE userdata SET protocol_version=?1 WHERE user_id=?2 AND protocol_version<?1", params!(version as i64, get_key(auth_key)));
}
pub fn save_acc_sif(auth_key: &str, data: JsonValue) {
save_data(auth_key, "sifcards", data);
}