Add custom card handling probably

This commit is contained in:
Ethan O'Brien
2026-07-31 20:32:02 -05:00
parent d077210efe
commit 26926e1a28
23 changed files with 3746 additions and 67 deletions
+540
View File
@@ -0,0 +1,540 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, JsonValue};
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("custom_cards.db", setup_tables);
}
// master_card_id == illust_prefix * 10000 + seq, and illust_id is derived from
// the same pair as {prefix:05}_{seq:04}_{00|01}. Official cards are 8-digit
// (prefix 1001-4014), the baked SIF1 import owns prefixes 10000-14999 (rows in
// client masterdata), so runtime uploads start at prefix 15000. seq 0 never
// exists - official illust ids start at 0001 - so an id landing on a prefix
// boundary is skipped, not issued
pub const FIRST_ILLUST_PREFIX: i64 = 15000;
pub const FIRST_CARD_ID: i64 = FIRST_ILLUST_PREFIX * 10000 + 1;
pub const LAST_CARD_ID: i64 = 999_999_999;
// Official characters are 1001-4014, the SIF1 import took 5001-5172 and
// 6001-6009 - client masterdata tops out at 6009, so runtime characters start
// at 7001. The ceiling keeps the id 5-digit (sign_{0:D5} asset naming) and
// below the 99999 sentinel SDCharacter substitutes for OTHER-category
// characters
pub const FIRST_CHARACTER_ID: i64 = 7001;
pub const LAST_CHARACTER_ID: i64 = 99_998;
// Cards and characters are one JSON blob each, in the exact shape
// /api/custom_card/list serves - except `published`/`obtainable`/`rarity`,
// which live in their own columns (the draw pool and the catalog filter query
// them) and are injected into the served object where the wire wants them
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS cards (
master_card_id BIGINT NOT NULL PRIMARY KEY,
master_character_id BIGINT NOT NULL,
owner_id BIGINT NOT NULL,
card TEXT NOT NULL,
rarity INT NOT NULL DEFAULT 1,
published INT NOT NULL DEFAULT 0,
obtainable INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS characters (
master_character_id BIGINT NOT NULL PRIMARY KEY,
owner_id BIGINT NOT NULL,
character TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS revision (
id INT NOT NULL PRIMARY KEY,
revision BIGINT NOT NULL,
last_card_id BIGINT NOT NULL,
last_character_id BIGINT NOT NULL
);
").unwrap();
}
pub fn get_revision() -> i64 {
DATABASE.lock_and_select("SELECT revision FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0)
}
// Bumped on every create/update/delete/publish/obtainable change so the client
// can tell its cached catalog is stale
pub fn bump_revision() {
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 1, 0, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!());
}
// Ids are never reused after a delete: a client that cached a dead id can't
// confuse it with a later upload, and a player's stored card row for a deleted
// card can't silently become a different card. last_card_id is the high-water
// mark and only ever rises, so MAX() over the live rows is a floor, not the
// answer
pub fn next_card_id() -> i64 {
let issued = DATABASE.lock_and_select("SELECT last_card_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
let max = DATABASE.lock_and_select("SELECT MAX(master_card_id) FROM cards", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
let mut rv = std::cmp::max(std::cmp::max(issued, max), FIRST_CARD_ID - 1) + 1;
// seq 0 doesn't exist in the illust naming scheme
if rv % 10000 == 0 {
rv += 1;
}
rv
}
pub fn next_character_id() -> i64 {
let issued = DATABASE.lock_and_select("SELECT last_character_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
let max = DATABASE.lock_and_select("SELECT MAX(master_character_id) FROM characters", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
std::cmp::max(std::cmp::max(issued, max), FIRST_CHARACTER_ID - 1) + 1
}
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) {
DATABASE.lock_and_exec(
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
);
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id));
}
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) {
DATABASE.lock_and_exec(
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
);
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id));
}
// The catalog blob only. The owner and the published/obtainable flags live in
// their own columns and are untouched here; rarity tracks the blob
pub fn update_card(master_card_id: i64, card: &JsonValue) {
DATABASE.lock_and_exec("UPDATE cards SET card=?1, rarity=?2 WHERE master_card_id=?3", params!(jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), master_card_id));
}
pub fn update_character(master_character_id: i64, character: &JsonValue) {
DATABASE.lock_and_exec("UPDATE characters SET character=?1 WHERE master_character_id=?2", params!(jzon::stringify(character.clone()), master_character_id));
}
pub fn delete_card(master_card_id: i64) {
DATABASE.lock_and_exec("DELETE FROM cards WHERE master_card_id=?1", params!(master_card_id));
}
pub fn delete_character(master_character_id: i64) {
DATABASE.lock_and_exec("DELETE FROM characters WHERE master_character_id=?1", params!(master_character_id));
}
// The stored blob with the column-backed wire field injected
fn card_with_flags(mut card: JsonValue) -> JsonValue {
let id = card["master_card_id"].as_i64().unwrap_or(0);
card["obtainable"] = is_obtainable(id).into();
card
}
pub fn get_card(master_card_id: i64) -> Option<JsonValue> {
let card = DATABASE.lock_and_select("SELECT card FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?;
Some(card_with_flags(jzon::parse(&card).ok()?))
}
pub fn get_character(master_character_id: i64) -> Option<JsonValue> {
let character = DATABASE.lock_and_select("SELECT character FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?;
jzon::parse(&character).ok()
}
pub fn get_card_owner(master_card_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT owner_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::<i64>().ok()
}
pub fn get_character_owner(master_character_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT owner_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?.parse::<i64>().ok()
}
// The character a runtime card belongs to, straight out of its own column.
// guest::proxy_card_id resolves an unviewable card through this, so it stays a
// plain lookup and never a decode of the blob
pub fn character_of(master_card_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT master_character_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::<i64>().ok()
}
pub fn is_published(master_card_id: i64) -> bool {
DATABASE.lock_and_select("SELECT published FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1"
}
pub fn set_published(master_card_id: i64, published: bool) {
DATABASE.lock_and_exec("UPDATE cards SET published=?1 WHERE master_card_id=?2", params!(published as i64, master_card_id));
}
pub fn is_obtainable(master_card_id: i64) -> bool {
DATABASE.lock_and_select("SELECT obtainable FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1"
}
pub fn set_obtainable(master_card_id: i64, obtainable: bool) {
DATABASE.lock_and_exec("UPDATE cards SET obtainable=?1 WHERE master_card_id=?2", params!(obtainable as i64, master_card_id));
}
pub fn has_character(master_character_id: i64) -> bool {
DATABASE.lock_and_select("SELECT master_character_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).is_ok()
}
pub fn card_count_for_owner(owner_id: i64) -> i64 {
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
}
// How many cards still point at this character. A referenced character can't
// be deleted
pub fn cards_using_character(master_character_id: i64) -> i64 {
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE master_character_id=?1", params!(master_character_id)).unwrap_or(0)
}
// A custom character is publicly visible when a published card references it -
// that's also what lets another uploader build a card on it
pub fn character_publicly_visible(master_character_id: i64) -> bool {
DATABASE.lock_and_select_type::<i64>(
"SELECT COUNT(*) FROM cards WHERE master_character_id=?1 AND published=1",
params!(master_character_id)
).unwrap_or(0) > 0
}
fn parse_blobs(rows: JsonValue) -> JsonValue {
let mut rv = array![];
for data in rows.members() {
if let Ok(parsed) = jzon::parse(&data.to_string()) {
rv.push(parsed).unwrap();
}
}
rv
}
// The card catalog this user is served: every published card, their own
// drafts, and any card their game account already owns (`owned` - so an
// unpublish never leaves a player holding an id their client can't resolve)
pub fn get_cards_for_user(user_id: i64, owned: &[i64]) -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all(
"SELECT card FROM cards WHERE published=1 OR owner_id=?1 ORDER BY master_card_id",
params!(user_id)
).unwrap_or(array![]));
let mut rv = array![];
let mut ids: Vec<i64> = Vec::new();
for card in rows.members() {
ids.push(card["master_card_id"].as_i64().unwrap_or(0));
rv.push(card_with_flags(card.clone())).unwrap();
}
for id in owned {
if ids.contains(id) {
continue;
}
if let Some(card) = get_card(*id) {
rv.push(card).unwrap();
}
}
rv
}
// The character catalog rides along with the cards: a custom character is
// included exactly when a served card references it, or the requester owns it
// (so a draft character never leaks, and the catalog is referentially closed -
// a served card can never name a master_character_id the same response failed
// to deliver)
pub fn get_characters_for_cards(user_id: i64, cards: &JsonValue) -> JsonValue {
let mut ids: Vec<i64> = Vec::new();
for card in cards.members() {
let id = card["master_character_id"].as_i64().unwrap_or(0);
if (FIRST_CHARACTER_ID..=LAST_CHARACTER_ID).contains(&id) && !ids.contains(&id) {
ids.push(id);
}
}
let own = DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(user_id)).unwrap_or(array![]);
for id in own.members() {
let id = id.as_i64().unwrap_or(0);
if !ids.contains(&id) {
ids.push(id);
}
}
ids.sort();
let mut rv = array![];
for id in ids {
if let Some(character) = get_character(id) {
rv.push(character).unwrap();
}
}
rv
}
// The custom characters this user may build a card on (mirrors
// validate_character_ref): their own, plus any that is publicly visible
// through a published card. Feeds the webui's character picker
pub fn get_selectable_characters(user_id: i64) -> JsonValue {
parse_blobs(DATABASE.lock_and_select_all("
SELECT character FROM characters
WHERE owner_id=?1 OR master_character_id IN (SELECT master_character_id FROM cards WHERE published=1)
ORDER BY master_character_id", params!(user_id)).unwrap_or(array![]))
}
// Card blobs plus the flag columns, for the webui manage view
pub fn get_cards_by_owner(owner_id: i64) -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id)).unwrap_or(array![]));
let mut rv = array![];
for card in rows.members() {
let mut card = card_with_flags(card.clone());
card["published"] = is_published(card["master_card_id"].as_i64().unwrap_or(0)).into();
rv.push(card).unwrap();
}
rv
}
pub fn get_characters_by_owner(owner_id: i64) -> JsonValue {
parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id)).unwrap_or(array![]))
}
// The webui card browser: every published card, plus the owner id so the page
// can label the uploader
pub fn get_browse_cards() -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE published=1 ORDER BY master_card_id", params!()).unwrap_or(array![]));
let mut rv = array![];
for card in rows.members() {
let mut card = card_with_flags(card.clone());
card["owner_id"] = get_card_owner(card["master_card_id"].as_i64().unwrap_or(0)).unwrap_or(0).into();
rv.push(card).unwrap();
}
rv
}
// Which of these candidate ids no longer exist. Only the runtime band is ever
// considered, and ids are never reused, so official (or imported) cards can't
// come back from this and a wipe is final. A card that's merely unpublished
// still has its row - only genuinely deleted ids are returned
pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; };
if (FIRST_CARD_ID..=LAST_CARD_ID).contains(&id) && !ids.contains(&id) {
ids.push(id);
}
}
if ids.is_empty() {
return array![];
}
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]);
let mut rv = array![];
for id in ids {
if !alive.contains(id) {
rv.push(id).unwrap();
}
}
rv
}
// The published + obtainable pool the custom gacha banner draws from, per
// rarity
pub fn obtainable_card_ids(rarity: i64) -> Vec<i64> {
let rows = DATABASE.lock_and_select_all(
"SELECT master_card_id FROM cards WHERE published=1 AND obtainable=1 AND rarity=?1 ORDER BY master_card_id",
params!(rarity)
).unwrap_or(array![]);
rows.members().filter_map(|id| id.as_i64()).collect()
}
// Resolve a content-addressed art md5 to the file under custom_cards/ that
// currently holds those bytes: card art lives at {card_id}/{kind}_{variant}.png,
// character art at characters/{character_id}/{kind}.png. Art is stored per
// entity (not a shared md5 store) and the catalog md5 always tracks the
// on-disk bytes - so this is the index the /custom_card/data/{md5} route
// serves from, and it self-heals: a replaced file gets a new md5 and the old
// one simply stops resolving
pub fn find_asset_by_md5(md5: &str) -> Option<String> {
let like = format!("%{}%", md5);
if let Ok(blob) = DATABASE.lock_and_select("SELECT card FROM cards WHERE card LIKE ?1", params!(like.clone())) {
if let Ok(card) = jzon::parse(&blob) {
let master_card_id = card["master_card_id"].as_i64()?;
for art in card["art"].members() {
if art["md5"].as_str() == Some(md5) {
return Some(format!("{}/{}_{}.png", master_card_id, art["kind"], art["variant"]));
}
}
}
}
let blob = DATABASE.lock_and_select("SELECT character FROM characters WHERE character LIKE ?1", params!(like)).ok()?;
let character = jzon::parse(&blob).ok()?;
let id = character["master_character_id"].as_i64()?;
for art in character["art"].members() {
if art["md5"].as_str() == Some(md5) {
return Some(format!("characters/{}/{}.png", id, art["kind"]));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use jzon::object;
fn card_blob(id: i64, rarity: i64) -> JsonValue {
object!{
"master_card_id": id,
"master_character_id": 1001,
"rarity": rarity,
"art": [{ "kind": "c", "variant": "00", "md5": format!("{:032x}", id), "size": 1 }]
}
}
fn wipe(owner: i64) {
for card in get_cards_by_owner(owner).members() {
delete_card(card["master_card_id"].as_i64().unwrap());
}
for character in get_characters_by_owner(owner).members() {
delete_character(character["master_character_id"].as_i64().unwrap());
}
}
// Ids are sequential from FIRST_CARD_ID, never reused after a delete, and
// never land on a seq-0 prefix boundary
#[test]
fn ids_are_sequential_and_never_reused() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3001);
let first = next_card_id();
assert!(first >= FIRST_CARD_ID);
assert_ne!(first % 10000, 0);
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false);
let second = next_card_id();
assert_eq!(second, first + 1);
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false);
delete_card(second);
assert_eq!(get_card(second), None);
// The high-water mark survives the delete, so the id is retired
assert_eq!(next_card_id(), second + 1);
let character = next_character_id();
assert!(character >= FIRST_CHARACTER_ID);
insert_character(character, 3001, &object!{ "master_character_id": character });
assert_eq!(next_character_id(), character + 1);
delete_character(character);
assert_eq!(next_character_id(), character + 1);
wipe(3001);
assert!(next_card_id() > second);
}
// Published cards go to everyone, drafts only to their owner, a game
// account that owns a card keeps seeing it even unpublished, and a
// character rides along with any card the viewer is served
#[test]
fn catalog_filters_per_user() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3003);
wipe(3004);
let character = next_character_id();
insert_character(character, 3003, &object!{ "master_character_id": character });
let published = next_card_id();
let mut blob = card_blob(published, 3);
blob["master_character_id"] = character.into();
insert_card(published, character, 3003, &blob, true, true);
let draft = next_card_id();
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false);
let owner_view = get_cards_for_user(3003, &[]);
assert_eq!(owner_view.len(), 2);
let other_view = get_cards_for_user(3004, &[]);
assert_eq!(other_view.len(), 1);
assert_eq!(other_view[0]["master_card_id"].as_i64(), Some(published));
// The wire `obtainable` field comes from the column
assert_eq!(other_view[0]["obtainable"].as_bool(), Some(true));
// A player who owns the draft in game still resolves it
let holder_view = get_cards_for_user(3004, &[draft]);
assert_eq!(holder_view.len(), 2);
// Unpublishing removes it from strangers, not from holders
set_published(published, false);
assert!(get_cards_for_user(3004, &[]).is_empty());
assert_eq!(get_cards_for_user(3004, &[published]).len(), 1);
set_published(published, true);
// Characters follow the served cards; a stranger with no visible card
// on the character doesn't get it
let characters = get_characters_for_cards(3004, &get_cards_for_user(3004, &[]));
assert_eq!(characters.len(), 1);
assert_eq!(characters[0]["master_character_id"].as_i64(), Some(character));
set_published(published, false);
assert!(get_characters_for_cards(3004, &get_cards_for_user(3004, &[])).is_empty());
// The owner always sees their own character
assert_eq!(get_characters_for_cards(3003, &get_cards_for_user(3003, &[])).len(), 1);
set_published(published, true);
assert_eq!(character_of(published), Some(character));
assert_eq!(cards_using_character(character), 2);
assert!(character_publicly_visible(character));
assert_eq!(card_count_for_owner(3003), 2);
wipe(3003);
wipe(3004);
}
// The draw pool is exactly the published + obtainable cards of the rarity
#[test]
fn obtainable_pool_by_rarity() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3005);
let r1 = next_card_id();
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true);
let r3 = next_card_id();
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true);
let unpublished = next_card_id();
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true);
let unobtainable = next_card_id();
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false);
assert_eq!(obtainable_card_ids(1), vec![r1]);
assert_eq!(obtainable_card_ids(3), vec![r3]);
assert!(obtainable_card_ids(2).is_empty());
set_obtainable(unobtainable, true);
assert_eq!(obtainable_card_ids(1), vec![r1, unobtainable]);
wipe(3005);
}
#[test]
fn md5_resolves_to_the_file_holding_the_bytes() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3007);
let id = next_card_id();
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false);
assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id)));
assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None);
let character = next_character_id();
insert_character(character, 3007, &object!{
"master_character_id": character,
"art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }]
});
assert_eq!(
find_asset_by_md5("aabbccddeeff00112233445566778899"),
Some(format!("characters/{}/icon.png", character))
);
wipe(3007);
}
// Deleted ids come back from dead_card_ids; unpublished, imported and
// official ids never do
#[test]
fn dead_ids_are_deleted_ids_only() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3008);
let alive = next_card_id();
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false);
let dead = next_card_id();
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false);
delete_card(dead);
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]);
assert_eq!(dead_ids.len(), 1);
assert_eq!(dead_ids[0].as_i64(), Some(dead));
wipe(3008);
}
}
+353
View File
@@ -0,0 +1,353 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use crate::router::global;
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("permissions.db", setup_tables);
}
// Scopes are flat dotted strings and imply everything below them: holding
// "card" grants "card.upload", and "*" grants everything. That hierarchy is
// the only notion of "level" - there is no rank integer, so a new capability
// is one line here and never a renumbering of anything else.
//
// Every call site must pass one of these consts, never a literal: an
// unrecognised scope string can only ever fail closed in has(), but grant()
// rejects it outright so a typo can't be persisted either.
pub const ALL: &str = "*";
pub const CARD: &str = "card";
// Create custom cards/characters, and edit or delete your OWN uploads
pub const CARD_UPLOAD: &str = "card.upload";
// Publish/unpublish and mark obtainable, on your OWN uploads
pub const CARD_PUBLISH: &str = "card.publish";
// Moderation: edit, delete, publish or unpublish ANYBODY's cards
pub const CARD_EDIT: &str = "card.edit";
pub const PERMISSION: &str = "permission";
pub const PERMISSION_GRANT: &str = "permission.grant";
pub const PERMISSION_REVOKE: &str = "permission.revoke";
// The whole grantable vocabulary, subtree roots included. Anything not in here
// cannot be written to the table
pub const SCOPES: &[&str] = &[
ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE
];
// Grants live in their own database rather than in userdata.db so that an
// account purge can never take administrative state with it
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS grants (
user_id BIGINT NOT NULL,
scope TEXT NOT NULL,
granted_by BIGINT NOT NULL,
granted_at BIGINT NOT NULL,
PRIMARY KEY (user_id, scope)
);
").unwrap();
}
// The owners are a process-level flag (--owner) rather than table rows: they
// are the bootstrap grantors, so they have to work on a fresh install with an
// empty (or hand-deleted) permissions.db, and they must not be revocable
// through the webui
fn is_owner(user_id: i64) -> bool {
user_id > 0 && crate::runtime::get_owners().contains(&user_id)
}
// Every scope that would satisfy a request for `scope`: "*", each dotted
// ancestor, and the scope itself. Matching is on whole dot-separated segments,
// so "car" never satisfies "card.upload"
fn implied_by(scope: &str) -> Vec<String> {
if scope == ALL {
return vec![String::from(ALL)];
}
let mut rv = vec![String::from(ALL)];
let mut prefix = String::new();
for part in scope.split('.') {
if !prefix.is_empty() {
prefix.push('.');
}
prefix.push_str(part);
rv.push(prefix.clone());
}
rv
}
fn held_scopes(user_id: i64) -> Vec<String> {
let rows = DATABASE.lock_and_select_all("SELECT scope FROM grants WHERE user_id=?1 ORDER BY scope", params!(user_id)).unwrap_or(array![]);
rows.members().map(|scope| scope.to_string()).collect()
}
fn insert(user_id: i64, scope: &str, granted_by: i64) {
DATABASE.lock_and_exec(
"INSERT OR IGNORE INTO grants (user_id, scope, granted_by, granted_at) VALUES (?1, ?2, ?3, ?4)",
params!(user_id, scope, granted_by, global::timestamp() as i64)
);
}
// A user with no row holds nothing at all
pub fn has(user_id: i64, scope: &str) -> bool {
if user_id <= 0 {
return false;
}
if is_owner(user_id) {
return true;
}
let held = held_scopes(user_id);
implied_by(scope).iter().any(|candidate| held.contains(candidate))
}
// Everything this user holds, for the webui to hide what it can't use. An
// owner's implicit "*" is reported here even though it has no row
pub fn scopes_for(user_id: i64) -> JsonValue {
if user_id <= 0 {
return array![];
}
let mut scopes: Vec<String> = Vec::new();
if is_owner(user_id) {
scopes.push(String::from(ALL));
}
for scope in held_scopes(user_id) {
if !scopes.contains(&scope) {
scopes.push(scope);
}
}
let mut rv = array![];
for scope in scopes {
rv.push(scope).unwrap();
}
rv
}
pub fn grants() -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let Ok(mut stmt) = conn.prepare("SELECT user_id, scope, granted_by, granted_at FROM grants ORDER BY user_id, scope") else {
return array![];
};
let Ok(mapped) = stmt.query_map(params!(), |row| {
Ok(object!{
user_id: row.get::<usize, i64>(0)?,
scope: row.get::<usize, String>(1)?,
granted_by: row.get::<usize, i64>(2)?,
granted_at: row.get::<usize, i64>(3)?
})
}) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
// The only way a row is ever written. Two conditions, both required:
//
// 1. the grantor holds permission.grant, and
// 2. the grantor holds the scope being granted
//
// (2) is what makes escalation impossible. has() only ever implies downwards,
// so holding a leaf never satisfies its parent - a user with card.upload can
// hand out card.upload and nothing else, and can no more grant themselves
// "card" (or "*") than they can grant it to anybody else. Both checks read the
// live table, so a revoked grantor loses the ability on their next request
pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
}
if !SCOPES.contains(&scope) {
return Err(format!("Unknown scope '{}'", scope));
}
if !has(granted_by, PERMISSION_GRANT) {
return Err(String::from("You do not have permission to grant scopes"));
}
if !has(granted_by, scope) {
return Err(format!("You cannot grant '{}' because you do not hold it yourself", scope));
}
insert(user_id, scope, granted_by);
Ok(())
}
// Revoking needs the same "you must hold it yourself" rule as granting, so a
// junior admin can't strip a senior one. An owner has no rows to delete, but
// the check is explicit so it stays true if that ever changes
pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
}
if !SCOPES.contains(&scope) {
return Err(format!("Unknown scope '{}'", scope));
}
if !has(revoked_by, PERMISSION_REVOKE) {
return Err(String::from("You do not have permission to revoke scopes"));
}
if !has(revoked_by, scope) {
return Err(format!("You cannot revoke '{}' because you do not hold it yourself", scope));
}
if is_owner(user_id) {
return Err(String::from("A server owner's scopes cannot be revoked"));
}
DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1 AND scope=?2", params!(user_id, scope));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// Every test holds the shared test data path lock, so uids are hand-picked
// to not collide between tests rather than cleaned up between them
fn wipe(user_id: i64) {
DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1", params!(user_id));
}
#[test]
fn subtree_implies_leaf_but_not_the_reverse() {
let _lock = crate::runtime::lock_test_data_path();
wipe(101);
assert!(!has(101, CARD_UPLOAD));
insert(101, CARD, 0);
assert!(has(101, CARD_UPLOAD));
assert!(has(101, CARD_EDIT));
assert!(has(101, CARD));
assert!(!has(101, PERMISSION_GRANT));
assert!(!has(101, ALL));
wipe(101);
insert(101, CARD_UPLOAD, 0);
assert!(has(101, CARD_UPLOAD));
assert!(!has(101, CARD));
assert!(!has(101, CARD_PUBLISH));
wipe(101);
}
#[test]
fn star_implies_everything() {
let _lock = crate::runtime::lock_test_data_path();
wipe(103);
insert(103, ALL, 0);
for scope in SCOPES {
assert!(has(103, scope), "scope {}", scope);
}
wipe(103);
}
#[test]
fn partial_segment_is_not_a_prefix() {
let _lock = crate::runtime::lock_test_data_path();
wipe(104);
insert(104, "car", 0);
assert!(!has(104, CARD_UPLOAD));
assert!(!has(104, CARD));
insert(104, "card.uplo", 0);
assert!(!has(104, CARD_UPLOAD));
wipe(104);
}
#[test]
fn absent_user_holds_nothing() {
let _lock = crate::runtime::lock_test_data_path();
wipe(105);
for scope in SCOPES {
assert!(!has(105, scope), "scope {}", scope);
}
assert!(scopes_for(105).is_empty());
assert!(!has(0, ALL));
assert!(!has(-1, ALL));
wipe(105);
}
#[test]
fn grant_and_revoke_need_the_scope_and_the_ability() {
let _lock = crate::runtime::lock_test_data_path();
for uid in [110, 111, 112, 113] {
wipe(uid);
}
// A grantor with permission.grant and card.upload can hand out
// card.upload and nothing else - escalation is impossible
insert(110, PERMISSION_GRANT, 0);
insert(110, CARD_UPLOAD, 0);
grant(111, CARD_UPLOAD, 110).unwrap();
grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent
assert_eq!(held_scopes(111), vec![String::from(CARD_UPLOAD)]);
assert!(grant(111, CARD_EDIT, 110).is_err());
assert!(grant(111, CARD, 110).is_err());
assert!(grant(110, ALL, 110).is_err());
assert!(!has(111, CARD_EDIT));
// No permission.grant / permission.revoke - no managing at all
insert(112, ALL, 0);
insert(113, CARD_UPLOAD, 0);
assert!(grant(113, CARD_UPLOAD, 113).is_err());
assert!(revoke(112, ALL, 113).is_err());
// Revoking needs the revoked scope held too
insert(110, PERMISSION_REVOKE, 0);
assert!(revoke(112, ALL, 110).is_err());
assert!(has(112, ALL));
revoke(111, CARD_UPLOAD, 110).unwrap();
assert!(!has(111, CARD_UPLOAD));
for uid in [110, 111, 112, 113] {
wipe(uid);
}
}
#[test]
fn unknown_scopes_are_rejected() {
let _lock = crate::runtime::lock_test_data_path();
wipe(116);
wipe(117);
insert(116, ALL, 0);
assert!(grant(117, "card.explode", 116).is_err());
assert!(revoke(117, "card.explode", 116).is_err());
assert!(grant(117, "song.upload", 116).is_err());
assert!(grant(0, CARD_UPLOAD, 116).is_err());
wipe(116);
wipe(117);
}
#[test]
fn owners_hold_everything_without_a_row() {
let _lock = crate::runtime::lock_test_data_path();
wipe(118);
wipe(119);
crate::runtime::update_owners(&[118, 120]);
for scope in SCOPES {
assert!(has(118, scope), "scope {}", scope);
assert!(has(120, scope), "scope {}", scope);
}
assert_eq!(scopes_for(118).len(), 1);
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL));
assert!(held_scopes(118).is_empty());
// An owner can bootstrap-grant, and can't be revoked
grant(119, ALL, 118).unwrap();
assert!(has(119, ALL));
insert(118, CARD_UPLOAD, 0);
assert!(revoke(118, CARD_UPLOAD, 119).is_err());
assert!(has(118, CARD_UPLOAD));
// Dropping owner status drops the implicit "*"
crate::runtime::update_owners(&[]);
assert!(!has(118, CARD_EDIT));
wipe(118);
wipe(119);
}
#[test]
fn the_vocabulary_is_wellformed() {
for scope in SCOPES {
assert!(!scope.is_empty());
assert!(!scope.ends_with('.'));
}
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE] {
assert!(SCOPES.contains(&scope), "scope {}", scope);
}
}
}