Files
SCOPENET-MC/panel/server/src/db.rs
T

649 lines
25 KiB
Rust

use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
/// Schema migrations, applied in order. Never edit a released entry —
/// append a new one instead.
const MIGRATIONS: &[&str] = &[
// 1: initial schema
r#"
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'player',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT
);
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '#7c5cff'
);
CREATE TABLE user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
icon_url TEXT,
banner_url TEXT,
mc_version TEXT NOT NULL,
loader TEXT NOT NULL DEFAULT 'vanilla',
loader_version TEXT,
source_kind TEXT NOT NULL DEFAULT 'vanilla',
source_label TEXT NOT NULL DEFAULT '',
source_ref TEXT NOT NULL DEFAULT '{}',
visibility TEXT NOT NULL DEFAULT 'public',
allowed_groups TEXT NOT NULL DEFAULT '[]',
memory_min INTEGER NOT NULL DEFAULT 1024,
memory_max INTEGER NOT NULL DEFAULT 4096,
jvm_args TEXT NOT NULL DEFAULT '',
server TEXT,
featured INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE instance_files (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
path TEXT NOT NULL,
url TEXT NOT NULL,
sha1 TEXT NOT NULL,
size INTEGER NOT NULL,
origin TEXT NOT NULL,
note TEXT,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id TEXT,
username TEXT,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX events_created ON events(created_at);
"#,
// 2: Yggdrasil auth server (UUIDs, skins, capes, sessions) and game
// server integration (plugin/mod tracking)
r#"
ALTER TABLE users ADD COLUMN uuid TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN skin_hash TEXT;
ALTER TABLE users ADD COLUMN skin_model TEXT NOT NULL DEFAULT 'classic';
ALTER TABLE users ADD COLUMN cape_id INTEGER;
ALTER TABLE users ADD COLUMN status_reason TEXT;
CREATE TABLE capes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hash TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
allowed_groups TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE TABLE ygg_tokens (
access_token TEXT PRIMARY KEY,
client_token TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX ygg_tokens_user ON ygg_tokens(user_id);
CREATE TABLE ygg_sessions (
server_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE player_keys (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
private_pem TEXT NOT NULL,
public_pem TEXT NOT NULL,
signature_v1 TEXT NOT NULL,
signature_v2 TEXT NOT NULL,
expires_at TEXT NOT NULL,
refreshed_after TEXT NOT NULL
);
CREATE TABLE launcher_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
CREATE TABLE game_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_hint TEXT NOT NULL,
access TEXT NOT NULL DEFAULT 'all',
allowed_groups TEXT NOT NULL DEFAULT '[]',
require_launcher INTEGER NOT NULL DEFAULT 0,
software TEXT,
mc_version TEXT,
plugin_version TEXT,
online_mode INTEGER,
max_players INTEGER NOT NULL DEFAULT 0,
online_count INTEGER NOT NULL DEFAULT 0,
tps REAL,
last_seen TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE server_online (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
name TEXT NOT NULL,
joined_at TEXT NOT NULL,
PRIMARY KEY (server_id, uuid)
);
CREATE TABLE player_stats (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
name TEXT NOT NULL,
playtime_secs INTEGER NOT NULL DEFAULT 0,
joins INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
player_kills INTEGER NOT NULL DEFAULT 0,
mob_kills INTEGER NOT NULL DEFAULT 0,
blocks_broken INTEGER NOT NULL DEFAULT 0,
blocks_placed INTEGER NOT NULL DEFAULT 0,
messages INTEGER NOT NULL DEFAULT 0,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (server_id, uuid)
);
CREATE INDEX player_stats_uuid ON player_stats(uuid);
CREATE TABLE server_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
uuid TEXT,
name TEXT,
kind TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX server_events_server ON server_events(server_id, id);
CREATE INDEX server_events_uuid ON server_events(uuid, id);
"#,
// 3: idempotent server stat batches (retained until the server is deleted).
r#"
CREATE TABLE server_sync_receipts (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
batch_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (server_id, batch_id)
);
"#,
// 4: permanent player identity, reserved historical names and audit data.
r#"
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE events ADD COLUMN uuid TEXT;
ALTER TABLE events ADD COLUMN detail TEXT;
ALTER TABLE events ADD COLUMN source TEXT NOT NULL DEFAULT 'launcher';
CREATE INDEX events_uuid ON events(uuid, id);
CREATE TABLE reserved_usernames (
name TEXT PRIMARY KEY COLLATE NOCASE,
uuid TEXT NOT NULL
);
INSERT INTO reserved_usernames SELECT username, uuid FROM users WHERE uuid <> '';
CREATE TRIGGER immutable_player_uuid BEFORE UPDATE OF uuid ON users
WHEN OLD.uuid <> '' AND NEW.uuid <> OLD.uuid
BEGIN SELECT RAISE(ABORT, 'player UUID is immutable'); END;
CREATE TRIGGER reserve_name_insert BEFORE INSERT ON users
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
CREATE TRIGGER reserve_name_update BEFORE UPDATE OF username ON users
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
CREATE TRIGGER remember_name_insert AFTER INSERT ON users
WHEN NEW.uuid <> ''
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
CREATE TRIGGER remember_name_update AFTER UPDATE OF username, uuid ON users
WHEN NEW.uuid <> ''
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
"#,
// 5: instance logo url and flexible launcher sessions.
r#"
ALTER TABLE instances ADD COLUMN logo_url TEXT;
CREATE TABLE launcher_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
ip TEXT NOT NULL,
username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO launcher_sessions_new (user_id, ip, created_at)
SELECT user_id, ip, created_at FROM launcher_sessions;
DROP TABLE launcher_sessions;
ALTER TABLE launcher_sessions_new RENAME TO launcher_sessions;
CREATE INDEX launcher_sessions_user ON launcher_sessions(user_id, created_at);
CREATE INDEX launcher_sessions_ip ON launcher_sessions(ip, created_at);
"#,
// 6: Leveling, Quests, Achievements, Guilds, Land Claims, Friends, Profiles & Social Posts
r#"
CREATE TABLE user_levels (
uuid TEXT PRIMARY KEY,
global_xp INTEGER NOT NULL DEFAULT 0,
global_level INTEGER NOT NULL DEFAULT 1,
title TEXT,
badges TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE TABLE server_levels (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
server_xp INTEGER NOT NULL DEFAULT 0,
server_level INTEGER NOT NULL DEFAULT 1,
rank_name TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (server_id, uuid)
);
CREATE TABLE level_rewards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level_type TEXT NOT NULL,
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
level_req INTEGER NOT NULL,
reward_type TEXT NOT NULL,
reward_name TEXT NOT NULL,
reward_data TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE quests (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
period TEXT NOT NULL,
category TEXT NOT NULL,
target_stat TEXT NOT NULL,
target_count INTEGER NOT NULL,
xp_reward INTEGER NOT NULL,
icon TEXT NOT NULL DEFAULT 'star',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE user_quests (
user_uuid TEXT NOT NULL,
quest_id TEXT NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
period_key TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
claimed INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_uuid, quest_id, period_key)
);
CREATE INDEX user_quests_uuid ON user_quests(user_uuid, period_key);
CREATE TABLE achievements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
icon_frame TEXT NOT NULL DEFAULT 'task',
icon_item TEXT NOT NULL DEFAULT 'diamond',
icon_bg TEXT NOT NULL DEFAULT 'stone',
icon_border TEXT NOT NULL DEFAULT 'gold',
requirement_type TEXT NOT NULL DEFAULT 'stat',
requirement_key TEXT NOT NULL,
requirement_value INTEGER NOT NULL DEFAULT 1,
xp_reward INTEGER NOT NULL DEFAULT 100,
secret INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE user_achievements (
user_uuid TEXT NOT NULL,
achievement_id TEXT NOT NULL REFERENCES achievements(id) ON DELETE CASCADE,
unlocked_at TEXT NOT NULL,
PRIMARY KEY (user_uuid, achievement_id)
);
CREATE INDEX user_achievements_uuid ON user_achievements(user_uuid);
CREATE TABLE guilds (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
tag TEXT NOT NULL COLLATE NOCASE,
description TEXT NOT NULL DEFAULT '',
motd TEXT NOT NULL DEFAULT '',
leader_uuid TEXT NOT NULL,
icon_url TEXT,
banner_url TEXT,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
max_claims INTEGER NOT NULL DEFAULT 16,
created_at TEXT NOT NULL
);
CREATE INDEX guilds_instance ON guilds(instance_id);
CREATE TABLE guild_members (
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL,
PRIMARY KEY (guild_id, uuid)
);
CREATE UNIQUE INDEX guild_member_unique ON guild_members(uuid);
CREATE TABLE guild_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
name TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
can_invite INTEGER NOT NULL DEFAULT 0,
can_kick INTEGER NOT NULL DEFAULT 0,
can_claim INTEGER NOT NULL DEFAULT 0,
can_post INTEGER NOT NULL DEFAULT 1,
can_manage INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE guild_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
author_uuid TEXT NOT NULL,
author_name TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX guild_posts_guild ON guild_posts(guild_id, id DESC);
CREATE TABLE guild_claims (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
dimension TEXT NOT NULL DEFAULT 'minecraft:overworld',
chunk_x INTEGER NOT NULL,
chunk_z INTEGER NOT NULL,
claimed_by_uuid TEXT NOT NULL,
claimed_at TEXT NOT NULL,
UNIQUE (server_id, dimension, chunk_x, chunk_z)
);
CREATE INDEX guild_claims_guild ON guild_claims(guild_id);
CREATE INDEX guild_claims_loc ON guild_claims(server_id, dimension, chunk_x, chunk_z);
CREATE TABLE friendships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL,
friend_uuid TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
action_uuid TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (user_uuid, friend_uuid)
);
CREATE INDEX friendships_user ON friendships(user_uuid);
CREATE INDEX friendships_friend ON friendships(friend_uuid);
CREATE TABLE direct_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_uuid TEXT NOT NULL,
sender_name TEXT NOT NULL,
recipient_uuid TEXT NOT NULL,
content TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX dm_conversation ON direct_messages(sender_uuid, recipient_uuid, id);
CREATE INDEX dm_recipient ON direct_messages(recipient_uuid, is_read);
CREATE TABLE game_invites (
id TEXT PRIMARY KEY,
sender_uuid TEXT NOT NULL,
sender_name TEXT NOT NULL,
recipient_uuid TEXT NOT NULL,
instance_id TEXT NOT NULL,
server_id INTEGER REFERENCES game_servers(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX game_invites_recipient ON game_invites(recipient_uuid, status);
CREATE TABLE user_profiles (
uuid TEXT PRIMARY KEY,
bio TEXT NOT NULL DEFAULT '',
banner_url TEXT,
custom_badge TEXT,
featured_achievement_id TEXT,
social_links TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
CREATE TABLE user_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_uuid TEXT NOT NULL,
author_name TEXT NOT NULL,
content TEXT NOT NULL,
image_url TEXT,
likes_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX user_posts_user ON user_posts(user_uuid, id DESC);
"#,
r#"
CREATE TABLE IF NOT EXISTS server_economy (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
username TEXT NOT NULL,
balance REAL NOT NULL DEFAULT 1000.0,
updated_at TEXT NOT NULL,
PRIMARY KEY (server_id, uuid)
);
CREATE INDEX IF NOT EXISTS economy_user ON server_economy(uuid);
CREATE INDEX IF NOT EXISTS economy_bal ON server_economy(server_id, balance DESC);
CREATE TABLE IF NOT EXISTS economy_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
from_uuid TEXT NOT NULL,
from_name TEXT NOT NULL,
to_uuid TEXT NOT NULL,
to_name TEXT NOT NULL,
amount REAL NOT NULL,
description TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS econ_tx_user ON economy_transactions(server_id, from_uuid, to_uuid);
CREATE TABLE IF NOT EXISTS server_market (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
seller_uuid TEXT NOT NULL,
seller_name TEXT NOT NULL,
item_id TEXT NOT NULL,
item_name TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 1,
price REAL NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS market_srv ON server_market(server_id, id DESC);
"#,
r#"
ALTER TABLE game_servers ADD COLUMN instance_id TEXT NOT NULL DEFAULT '';
DROP INDEX guild_member_unique;
CREATE INDEX guild_member_uuid ON guild_members(uuid);
CREATE TRIGGER guild_members_instance_unique BEFORE INSERT ON guild_members
WHEN EXISTS (SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id
WHERE gm.uuid = NEW.uuid AND gm.guild_id <> NEW.guild_id
AND g.instance_id = (SELECT instance_id FROM guilds WHERE id = NEW.guild_id))
BEGIN SELECT RAISE(ABORT, 'Already in a guild for this instance'); END;
CREATE TRIGGER achievement_xp AFTER INSERT ON user_achievements BEGIN
INSERT INTO user_levels(uuid, global_xp, updated_at)
VALUES(NEW.user_uuid, (SELECT MAX(0, xp_reward) FROM achievements WHERE id = NEW.achievement_id), NEW.unlocked_at)
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp, updated_at = excluded.updated_at;
END;
INSERT INTO user_levels(uuid, global_xp, updated_at)
SELECT ua.user_uuid, SUM(MAX(0, a.xp_reward)), MAX(ua.unlocked_at) FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id GROUP BY ua.user_uuid
ON CONFLICT(uuid) DO UPDATE SET global_xp = global_xp + excluded.global_xp;
CREATE TABLE granted_rewards (uuid TEXT NOT NULL, reward_id INTEGER NOT NULL REFERENCES level_rewards(id) ON DELETE CASCADE,
granted_at TEXT NOT NULL, PRIMARY KEY (uuid, reward_id));
ALTER TABLE server_market ADD COLUMN item_data TEXT;
CREATE TABLE economy_operations (server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
operation_id TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(server_id, operation_id));
"#,
r#"
CREATE TABLE user_post_likes (
post_id INTEGER NOT NULL REFERENCES user_posts(id) ON DELETE CASCADE,
user_uuid TEXT NOT NULL,
PRIMARY KEY (post_id, user_uuid)
);
"#,
r#"
ALTER TABLE game_servers ADD COLUMN map_url TEXT NOT NULL DEFAULT '';
"#,
r#"
CREATE TABLE account_connections (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (provider, provider_id),
UNIQUE (user_id, provider)
);
CREATE TABLE oauth_attempts (
state TEXT PRIMARY KEY,
kind TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
result TEXT,
consumed_at TEXT
);
CREATE TABLE password_resets (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX password_resets_user ON password_resets(user_id);
"#,
r#"
CREATE TABLE guild_wallets (
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
balance REAL NOT NULL DEFAULT 0 CHECK(balance >= 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(server_id,guild_id)
);
CREATE TABLE guild_wallet_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
guild_id TEXT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
actor_uuid TEXT NOT NULL,
kind TEXT NOT NULL,
amount REAL NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX guild_wallet_transactions_recent ON guild_wallet_transactions(guild_id,server_id,id DESC);
"#,
r#"
ALTER TABLE game_servers ADD COLUMN live_map_enabled INTEGER NOT NULL DEFAULT 0;
"#,
];
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
std::fs::create_dir_all(data_dir)?;
let url = format!("sqlite://{}", data_dir.join("panel.db").display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
pub async fn connect_memory() -> Result<SqlitePool> {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?;
migrate(&pool).await?;
Ok(pool)
}
async fn migrate(pool: &SqlitePool) -> Result<()> {
let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?;
for (i, sql) in MIGRATIONS.iter().enumerate() {
let version = i as i64 + 1;
if version <= current {
continue;
}
let mut tx = pool.begin().await?;
sqlx::raw_sql(sql).execute(&mut *tx).await?;
sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?;
tx.commit().await?;
tracing::info!("applied database migration {version}");
}
backfill_uuids(pool).await?;
crate::seed::seed_quests_and_achievements(pool).await?;
if current < 9 {
crate::seed::upgrade_seeded_quest_targets(pool).await?;
}
Ok(())
}
/// Accounts created before the auth server existed get the offline-mode UUID
/// for their name — the one offline servers already knew them by.
async fn backfill_uuids(pool: &SqlitePool) -> Result<()> {
let missing: Vec<(i64, String)> = sqlx::query_as("SELECT id, username FROM users WHERE uuid = ''").fetch_all(pool).await?;
for (id, name) in missing {
sqlx::query("UPDATE users SET uuid = ? WHERE id = ?").bind(scopenet_shared::offline_uuid(&name)).bind(id).execute(pool).await?;
}
if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE name = 'users_uuid'").fetch_one(pool).await? == 0 {
sqlx::raw_sql("CREATE UNIQUE INDEX users_uuid ON users(uuid)").execute(pool).await?;
}
Ok(())
}
pub fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn identity_migration_preserves_existing_player_data_keys() {
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
sqlx::raw_sql(MIGRATIONS[0]).execute(&pool).await.unwrap();
sqlx::raw_sql(MIGRATIONS[1]).execute(&pool).await.unwrap();
let existing = scopenet_shared::offline_uuid("LegacyPlayer");
sqlx::query("INSERT INTO users(username,password_hash,created_at,uuid) VALUES('LegacyPlayer','test',?,?)")
.bind(now())
.bind(&existing)
.execute(&pool)
.await
.unwrap();
sqlx::raw_sql("PRAGMA user_version=2").execute(&pool).await.unwrap();
migrate(&pool).await.unwrap();
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='LegacyPlayer'").fetch_one(&pool).await.unwrap();
assert_eq!(uuid, existing);
sqlx::query("UPDATE users SET username='RenamedPlayer' WHERE uuid=?").bind(&existing).execute(&pool).await.unwrap();
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='RenamedPlayer'").fetch_one(&pool).await.unwrap();
assert_eq!(uuid, existing);
let owner: String =
sqlx::query_scalar("SELECT uuid FROM reserved_usernames WHERE name='legacyplayer'").fetch_one(&pool).await.unwrap();
assert_eq!(owner, existing);
}
}