Announcements working

This commit is contained in:
Ethan O'Brien
2026-08-12 17:21:52 -05:00
parent 8f7346d09e
commit cb40d74c2c
27 changed files with 1871 additions and 441 deletions
+267
View File
@@ -0,0 +1,267 @@
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("announcements.db", setup_tables);
}
// 1 = notice, 2 = update, 3 = bug
pub const CATEGORIES: &[i64] = &[1, 2, 3];
pub const TYPES: &[&str] = &["news", "event", "gacha", "maintenance", "shop", "others"];
pub fn is_valid_category(category: i64) -> bool {
CATEGORIES.contains(&category)
}
pub fn is_valid_type(kind: &str) -> bool {
TYPES.contains(&kind)
}
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
banner BLOB,
updated INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
published_at BIGINT NOT NULL,
created_by BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
").unwrap();
}
pub enum Banner {
Keep,
Clear,
Set(Vec<u8>)
}
fn row_to_json(row: &rusqlite::Row) -> rusqlite::Result<JsonValue> {
Ok(object!{
id: row.get::<usize, i64>(0)?,
category: row.get::<usize, i64>(1)?,
type: row.get::<usize, String>(2)?,
title: row.get::<usize, String>(3)?,
body: row.get::<usize, String>(4)?,
has_banner: row.get::<usize, i64>(5)? != 0,
updated: row.get::<usize, i64>(6)? != 0,
visible: row.get::<usize, i64>(7)? != 0,
published_at: row.get::<usize, i64>(8)?,
created_by: row.get::<usize, i64>(9)?,
created_at: row.get::<usize, i64>(10)?
})
}
const COLUMNS: &str = "id, category, type, title, body, banner IS NOT NULL, updated, visible, published_at, created_by, created_at";
fn query(where_clause: &str, args: &[&dyn rusqlite::ToSql]) -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements {where_clause} ORDER BY published_at DESC, id DESC");
let Ok(mut stmt) = conn.prepare(&sql) else {
return array![];
};
let Ok(mapped) = stmt.query_map(args, |row| row_to_json(row)) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
pub fn list_category(category: i64) -> JsonValue {
query("WHERE visible=1 AND category=?1", params!(category))
}
pub fn get_all() -> JsonValue {
query("", params!())
}
pub fn get(id: i64) -> Option<JsonValue> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements WHERE id=?1");
conn.query_row(&sql, params!(id), |row| row_to_json(row)).ok()
}
pub fn get_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn get_public_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND visible=1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn visible_ids(category: Option<i64>) -> Vec<i64> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let (sql, args): (&str, Vec<&dyn rusqlite::ToSql>) = match &category {
Some(c) => ("SELECT id FROM announcements WHERE visible=1 AND category=?1", vec![c]),
None => ("SELECT id FROM announcements WHERE visible=1", vec![])
};
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
let Ok(mapped) = stmt.query_map(args.as_slice(), |row| row.get::<usize, i64>(0)) else {
return Vec::new();
};
mapped.flatten().collect()
}
pub fn latest_published_at() -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT MAX(published_at) FROM announcements WHERE visible=1", params!(), |row| row.get::<usize, i64>(0)).unwrap_or(0)
}
pub fn create(category: i64, kind: &str, title: &str, body: &str, banner: Option<Vec<u8>>, updated: bool, visible: bool, published_at: i64, created_by: i64) -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"INSERT INTO announcements (category, type, title, body, banner, updated, visible, published_at, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params!(category, kind, title, body, banner, updated as i64, visible as i64, published_at, created_by, global::timestamp() as i64)
).unwrap();
conn.last_insert_rowid()
}
pub fn update(id: i64, category: i64, kind: &str, title: &str, body: &str, banner: Banner, updated: bool, visible: bool, published_at: i64) {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"UPDATE announcements SET category=?2, type=?3, title=?4, body=?5, updated=?6, visible=?7, published_at=?8 WHERE id=?1",
params!(id, category, kind, title, body, updated as i64, visible as i64, published_at)
).unwrap();
match banner {
Banner::Keep => {},
Banner::Clear => { conn.execute("UPDATE announcements SET banner=NULL WHERE id=?1", params!(id)).unwrap(); },
Banner::Set(bytes) => { conn.execute("UPDATE announcements SET banner=?2 WHERE id=?1", params!(id, bytes)).unwrap(); }
}
}
pub fn delete(id: i64) {
DATABASE.lock_and_exec("DELETE FROM announcements WHERE id=?1", params!(id));
}
/// more tests??? This is probably a good thing but man haha
#[cfg(test)]
mod tests {
use super::*;
fn wipe() {
DATABASE.lock_and_exec("DELETE FROM announcements", params!());
}
#[test]
fn create_list_and_category_filter() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let a = create(1, "news", "First", "<p>body</p>", None, false, true, 1000, 42);
let b = create(1, "event", "Second", "body", Some(vec![1, 2, 3]), true, true, 2000, 42);
let c = create(2, "gacha", "Other tab", "body", None, false, true, 1500, 42);
let hidden = create(1, "news", "Draft", "body", None, false, false, 3000, 42);
// One tab, visible only, newest first
let cat1 = list_category(1);
assert_eq!(cat1.len(), 2);
assert_eq!(cat1[0]["id"].as_i64(), Some(b));
assert_eq!(cat1[1]["id"].as_i64(), Some(a));
assert!(cat1.members().all(|row| row["id"].as_i64() != Some(hidden)));
// has_banner reflects the blob without carrying it
assert_eq!(cat1[0]["has_banner"].as_bool(), Some(true));
assert_eq!(cat1[1]["has_banner"].as_bool(), Some(false));
assert_eq!(banner(b), Some(vec![1, 2, 3]));
assert_eq!(banner(a), None);
// The other tab is untouched, the admin view sees the draft too
assert_eq!(list_category(2).len(), 1);
assert_eq!(list_category(2)[0]["id"].as_i64(), Some(c));
assert_eq!(get_all().len(), 4);
assert_eq!(latest_published_at(), 2000);
let mut visible = visible_ids(None);
visible.sort();
assert_eq!(visible, vec![a, b, c]);
let mut cat1_ids = visible_ids(Some(1));
cat1_ids.sort();
assert_eq!(cat1_ids, vec![a, b]);
wipe();
}
#[test]
fn update_rewrites_and_banner_transitions() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let id = create(1, "news", "Title", "body", Some(vec![9]), false, true, 1000, 7);
update(id, 3, "maintenance", "New title", "new body", Banner::Keep, true, true, 5000);
let row = get(id).unwrap();
assert_eq!(row["category"].as_i64(), Some(3));
assert_eq!(row["type"].as_str(), Some("maintenance"));
assert_eq!(row["title"].as_str(), Some("New title"));
assert_eq!(row["updated"].as_bool(), Some(true));
assert_eq!(row["published_at"].as_i64(), Some(5000));
// Keep left the blob in place
assert_eq!(banner(id), Some(vec![9]));
update(id, 3, "maintenance", "New title", "new body", Banner::Set(vec![4, 5]), true, true, 5000);
assert_eq!(banner(id), Some(vec![4, 5]));
update(id, 3, "maintenance", "New title", "new body", Banner::Clear, true, false, 5000);
assert_eq!(banner(id), None);
assert_eq!(get(id).unwrap()["visible"].as_bool(), Some(false));
delete(id);
assert!(get(id).is_none());
wipe();
}
// Ids are sequential, so the player-facing banner route is pollable: a
// draft's banner must be reachable by the admin lookup and by nothing else
#[test]
fn a_drafts_banner_is_admin_only() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let published = create(1, "news", "Live", "body", Some(vec![1, 2]), false, true, 1000, 42);
let draft = create(1, "news", "Unannounced", "body", Some(vec![7, 7]), false, false, 2000, 42);
assert_eq!(banner(draft), Some(vec![7, 7]));
assert_eq!(visible_banner(draft), None);
assert_eq!(visible_banner(published), Some(vec![1, 2]));
// Publishing it makes the banner reachable, hiding it again takes it back
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, true, 2000);
assert_eq!(visible_banner(draft), Some(vec![7, 7]));
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, false, 2000);
assert_eq!(visible_banner(draft), None);
wipe();
}
#[test]
fn vocabulary_is_wellformed() {
for category in CATEGORIES {
assert!(is_valid_category(*category));
}
assert!(!is_valid_category(0));
assert!(!is_valid_category(4));
for kind in TYPES {
assert!(is_valid_type(kind));
}
assert!(!is_valid_type("explosion"));
}
}
+20 -47
View File
@@ -9,38 +9,28 @@ 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 ANNOUNCEMENT: &str = "announcement";
pub const ANNOUNCEMENT_MANAGE: &str = "announcement.manage";
pub const SCOPES: &[&str] = &[
ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE
];
// 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 (
@@ -53,18 +43,11 @@ CREATE TABLE IF NOT EXISTS grants (
").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> {
fn has_permission(scope: &str) -> Vec<String> {
if scope == ALL {
return vec![String::from(ALL)];
}
@@ -80,7 +63,7 @@ fn implied_by(scope: &str) -> Vec<String> {
rv
}
fn held_scopes(user_id: i64) -> Vec<String> {
fn get_permissions(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()
}
@@ -100,13 +83,11 @@ pub fn has(user_id: i64, scope: &str) -> bool {
if is_owner(user_id) {
return true;
}
let held = held_scopes(user_id);
implied_by(scope).iter().any(|candidate| held.contains(candidate))
let held = get_permissions(user_id);
has_permission(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 {
pub fn get_user_permissions(user_id: i64) -> JsonValue {
if user_id <= 0 {
return array![];
}
@@ -114,7 +95,7 @@ pub fn scopes_for(user_id: i64) -> JsonValue {
if is_owner(user_id) {
scopes.push(String::from(ALL));
}
for scope in held_scopes(user_id) {
for scope in get_permissions(user_id) {
if !scopes.contains(&scope) {
scopes.push(scope);
}
@@ -148,16 +129,6 @@ pub fn grants() -> JsonValue {
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"));
@@ -175,9 +146,6 @@ pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
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"));
@@ -198,6 +166,11 @@ pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String>
Ok(())
}
// rest of file is tests that ai wrote
// I didn't read through them because I don't super care about tests but they probably do something
#[cfg(test)]
mod tests {
use super::*;
@@ -276,7 +249,7 @@ mod tests {
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_eq!(get_permissions(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());
@@ -326,7 +299,7 @@ mod tests {
}
assert_eq!(scopes_for(118).len(), 1);
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL));
assert!(held_scopes(118).is_empty());
assert!(get_permissions(118).is_empty());
// An owner can bootstrap-grant, and can't be revoked
grant(119, ALL, 118).unwrap();
assert!(has(119, ALL));
@@ -346,7 +319,7 @@ mod tests {
assert!(!scope.is_empty());
assert!(!scope.ends_with('.'));
}
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE] {
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE] {
assert!(SCOPES.contains(&scope), "scope {}", scope);
}
}