Compare commits

..

5 Commits

Author SHA1 Message Date
Ethan O'Brien
6523ad4d4a Remove old asset lists / masterdata endpoints 2026-07-26 23:28:05 -05:00
Ethan O'Brien
2056511bd1 Add missing gree endpoints 2026-07-26 23:22:23 -05:00
Ethan O'Brien
352ff2dbc5 Cleanly handle unverified gree devices 2026-07-26 23:15:02 -05:00
Ethan O'Brien
b5bd8ad4fb Sync new_skill.csv 2026-07-26 23:06:55 -05:00
Ethan O'Brien
3ca5dcf1ea Better support for custom card account flagging 2026-07-26 22:02:01 -05:00
19 changed files with 738 additions and 101201 deletions

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

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());

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
}

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))

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

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)))
}

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()
};

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

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),
}
}

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);

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"
}

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,

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;
}
}

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) {

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);
}