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 1872 additions and 442 deletions

View File

@@ -2,3 +2,4 @@ pub mod gree;
pub mod custom_song;
pub mod custom_card;
pub mod permissions;
pub mod announcements;

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

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

View File

@@ -226,7 +226,6 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
}
} else {
match req.path() {
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req),
@@ -292,4 +291,5 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
);
cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes);
cfg.configure(web::routes);
}

View File

@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
let body = jzon::parse(&body).unwrap();
let migration_code = body["migration_code"].to_string();
let password = decrypt_transfer_password(&body["migration_password"].to_string());
let user = userdata::user::migration::get_acc_transfer(&body["migration_code"].to_string(), &password);
let user = userdata::user::migration::get_acc_transfer(&migration_code, &password);
let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
// The gree error envelope REQUIRES `code` and `message` on a failure. The old
// {result:"ERR", messsage:"User Not Found"} shape had neither (and misspelt the key),
// which breaks both native gamelibs:
// * iOS: +[GGLError errorWithJson:] builds
// @{NSLocalizedDescriptionKey: message} with message == nil ->
// NSInvalidArgumentException "attempt to insert nil object from objects[0]" -> the app
// hard-crashes the moment a wrong password / unknown code is entered on the new device.
// (It also reads `code` as [nil integerValue] == 0 == SUCCESS, so a non-crashing build
// would have run the success path on an empty payload.)
// * managed: Shock.GGL.Payment.GGLVerifyMigrationCode.CanRetry(code) tests
// code == 7004 (kGGLPErrorVerifyMigrationIncorrectPassword) to show the
// "re-enter your password" dialog instead of the fatal error dialog.
// 7002 = kGGLPErrorVerifyMigrationCodeNotExist, 7004 = incorrect password.
let (code, message) = if userdata::user::migration::transfer_code_exists(&migration_code) {
(7004, "Migration password is incorrect")
} else {
(7002, "Migration code does not exist")
};
object!{
result: "ERR",
messsage: "User Not Found"
result: "NG",
code: code,
message: message
}
} else {
let data_user = userdata::get_acc(&user["login_token"].to_string());

View File

@@ -124,6 +124,10 @@ async fn home(Login(key): Login) -> impl Responder {
}
user["home"]["unread_chat_count"] = chat_count.into();
let seen_at = user["home"]["announcement_seen_at"].as_i64().unwrap_or(0);
let new_announcement = crate::database::announcements::latest_published_at() > seen_at;
user["home"]["new_announcement_flag"] = (new_announcement as i32).into();
//todo
user["home"]["beginner_mission_complete"] = 1.into();

View File

@@ -289,10 +289,8 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
return Api(None);
}
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
// party members post. Nothing about STARTING a live depends on which room it came
// from — the one thing read out of it is the room's privacy, and that is read here
// rather than at /multi_live/end because here the room is certainly still alive (see
// record_room_privacy). Reconciling the party itself — checking the four results
// party members post. It is recorded verbatim on the start record and nothing reads
// anything else out of it. Reconciling the party itself — checking the four results
// against each other — is still deferred.
// master_event_id is recorded verbatim and never validated here: multi is a permanent
// feature entered against a closed event (see scoring_event), so a closed — or absent
@@ -329,7 +327,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
userdata::save_acc(&key, user);
body["use_lp"] = consumed.into();
record_room_privacy(&mut body);
live::start_live(&key, &body);
Api(Some(object!{
@@ -337,51 +334,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
}))
}
// Whether this live was played in a private (join-by-code) party, which is the one thing
// /multi_live/end reads the room `token` for.
//
// Officially a multi live never updated the score board at all — the client says so on the
// result screen. That stays true for PUBLIC matchmaking; a private party of friends is a
// deliberate divergence and keeps its scores. The room is looked up in the relay's live
// registry, which sits in this same process, so nothing new travels on the wire.
//
// Note the privacy answer is the room's CREATION-time one. The current visible/open flags
// cannot be used: the game hides a public room too, as soon as its members are decided
// (MultiMatchingView.SetRoomOpenVisible(false)), so every room in a live looks unlisted.
fn is_private_party(body: &JsonValue) -> bool {
rooms::token_is_private_room(body["token"].as_str().unwrap_or_default())
}
// The key the answer above is stashed under on the start record.
const RECORDED_PRIVATE: &str = "room_private";
// Asked once, at /multi_live/start, and written onto the payload start_live records.
//
// Asking at /multi_live/end instead made the answer depend on WHEN each of the up to four
// party members got its POST in: the room dies with its last clean leave, so an ender that
// posted after the party broke up saw no room and fell back to "public" while the others
// had already been told "private" — the same live scored differently per player, and a
// private party silently lost its score board. At start time the room is by definition
// alive (its master minted the token moments earlier and every member is still seated), so
// every member records the same flag off the same room.
//
// A registry miss records nothing at all rather than `false`: the end-side lookup is kept
// as the fallback for records written before this existed, and "absent" is what selects it.
fn record_room_privacy(body: &mut JsonValue) {
if let Some(private) = rooms::token_room_privacy(body["token"].as_str().unwrap_or_default()) {
body[RECORDED_PRIVATE] = private.into();
}
}
// What /multi_live/end obeys: the flag recorded at start when there is one, and the live
// registry lookup only for a record that predates it.
fn recorded_privacy(started: Option<&JsonValue>, body: &JsonValue) -> bool {
match started.and_then(|s| s[RECORDED_PRIVATE].as_bool()) {
Some(private) => private,
None => is_private_party(body)
}
}
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
// Older clients speak an incompatible multi — refuse before touching any state
// (in particular before the start record can be consumed).
@@ -458,21 +410,12 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder
}
}
// A public room scores like the official server did: no high score, no score board.
// Read off the start record, so every member of one party gets the same answer no
// matter how late its POST lands. Only a record from before that was recorded falls
// back to a live lookup, where a room the registry no longer knows about (torn down,
// or a restart since the live started) is treated as public — the behaviour to be
// wrong on.
let private = recorded_privacy(started, &body);
println!(
"multi_live/end: uid {} room is {} — score board {}",
uid,
if private { "PRIVATE" } else { "PUBLIC/unknown" },
if private { "updated" } else { "skipped (official)" }
);
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, private);
// A multi live scores like the official server did: no high score, no score board
// the client's own result screen says so. Private (join-by-code) parties are no
// exception (Ethan 2026-08-12; an earlier build recorded private-party scores, and
// the room-privacy plumbing that told the two apart left with that behaviour). The
// clear count and max combo still record either way — the live really was played.
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, false);
rv["is_penalty_miss_ratio"] = status.into();
// Fields RecvMultiLiveEndRData declares that live_end does not emit.
@@ -837,166 +780,6 @@ mod tests {
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
}
// --- private vs public rooms (the score-board branch) --------------------------
//
// The relay runs in this process, so the end handler can ask the room registry what
// kind of room a finishing live belongs to. These drive the REAL (global) registry
// through the same entry points ws.rs uses, then ask is_private_party exactly what the
// handler asks it. Room names and tokens are unique per test, so they do not collide
// with each other in the shared registry.
fn open_room(name: &str, visible: bool, token: &str) -> rooms::ConnId {
// The receiver is dropped immediately: nothing here reads the relay's outbound
// frames, and a send to a gone writer is already a no-op (Registry::send).
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut reg = rooms::registry();
let id = reg.connect(1, tx, Instant::now());
reg.handle(id, ClientMsg::CreateRoom {
name: name.to_string(),
max_players: 4,
visible,
open: true,
props: Map::new(),
lobby_prop_keys: vec![],
player_props: Map::new(),
}, Instant::now());
// MultiEventMatchingScene mints the token and pushes the room bag just before the
// live starts; it is the same string the client then POSTs to /multi_live/end.
reg.handle(id, ClientMsg::SetRoomProps {
props: Map::from_pairs(vec![("C", Value::Str(token.to_string()))]),
}, Instant::now());
id
}
fn set_room_props(id: rooms::ConnId, props: Vec<(&str, Value)>) {
rooms::registry().handle(id, ClientMsg::SetRoomProps { props: Map::from_pairs(props) }, Instant::now());
}
// A clean leave by the last active player destroys the room, exactly as a party
// breaking up after the results does.
fn close_room(id: rooms::ConnId) {
rooms::registry().handle(id, ClientMsg::LeaveRoom, Instant::now());
}
#[test]
fn a_private_party_is_recognised_by_its_room_token() {
let room = open_room("042719", false, "1.private-party");
assert!(is_private_party(&object!{ token: "1.private-party" }));
close_room(room);
}
#[test]
fn a_public_room_stays_public_once_the_game_hides_it() {
// The whole reason privacy is latched at creation: MultiMatchingView closes and
// hides a PUBLIC room the moment its members are decided, so mid-live its flags are
// indistinguishable from a private room's.
let room = open_room("1234567", true, "2.public-room");
assert!(!is_private_party(&object!{ token: "2.public-room" }));
set_room_props(room, vec![("#253", Value::Bool(false)), ("#254", Value::Bool(false))]);
assert!(
!is_private_party(&object!{ token: "2.public-room" }),
"a hidden public room must not start updating score boards"
);
close_room(room);
}
#[test]
fn a_token_no_room_claims_falls_back_to_public() {
// Torn down before the end arrived, or a restart since the live started.
let room = open_room("3336667", true, "3.gone-by-then");
close_room(room);
assert!(!is_private_party(&object!{ token: "3.gone-by-then" }));
// Never existed, and an end with no token at all.
assert!(!is_private_party(&object!{ token: "4.never-existed" }));
assert!(!is_private_party(&object!{}));
}
// --- privacy is decided ONCE, at start ------------------------------------------
#[test]
fn the_privacy_answer_is_recorded_at_start_and_outlives_the_room() {
let room = open_room("551234", false, "5.recorded-private");
let mut start_body = object!{ token: "5.recorded-private", master_live_id: STOCK_LIVE_ID };
record_room_privacy(&mut start_body);
assert_eq!(start_body["room_private"].as_bool(), Some(true));
// The party breaks up: the room is gone by the time the ends land.
close_room(room);
assert!(!is_private_party(&start_body), "the live lookup can no longer answer");
// The recorded answer still does, so the score board is still updated.
assert!(recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn every_ender_of_one_party_reads_the_same_answer() {
// Four members, one room, four /multi_live/start posts — and then the ends arrive
// spread out, the last one after the room has been torn down. Asking the registry
// at END time made the last poster's live PUBLIC while the first three's were
// PRIVATE: one live, scored two different ways.
let room = open_room("552345", false, "6.four-enders");
let mut records: Vec<JsonValue> = (0..4)
.map(|_| object!{ token: "6.four-enders", master_live_id: STOCK_LIVE_ID })
.collect();
for record in records.iter_mut() {
record_room_privacy(record);
}
// Three end while the room is alive, the fourth after it is gone.
let end_body = object!{ token: "6.four-enders" };
let mut answers: Vec<bool> = records[..3]
.iter()
.map(|r| recorded_privacy(Some(r), &end_body))
.collect();
close_room(room);
answers.push(recorded_privacy(Some(&records[3]), &end_body));
assert_eq!(answers, vec![true; 4], "one live must score one way for everybody");
}
#[test]
fn a_registry_miss_at_end_no_longer_flips_a_private_live_to_public() {
let room = open_room("553456", false, "7.miss-at-end");
let mut start_body = object!{ token: "7.miss-at-end" };
record_room_privacy(&mut start_body);
close_room(room);
// The end-time lookup misses and resolves to public...
assert!(!is_private_party(&start_body));
// ...but it is not what is asked any more.
assert!(recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn a_public_room_records_public_and_stays_public() {
let room = open_room("554567", true, "8.recorded-public");
let mut start_body = object!{ token: "8.recorded-public" };
record_room_privacy(&mut start_body);
assert_eq!(start_body["room_private"].as_bool(), Some(false));
assert!(!recorded_privacy(Some(&start_body), &start_body));
close_room(room);
assert!(!recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn a_record_with_no_recorded_answer_falls_back_to_the_live_lookup() {
// Started before this was recorded, or started against a token the registry did
// not know (a start POST that arrived after its own room died). Nothing is written
// in that case, deliberately, so the fallback is what selects it.
let room = open_room("555678", false, "9.no-record");
let mut missed = object!{ token: "9.never-a-room" };
record_room_privacy(&mut missed);
assert!(missed["room_private"].is_null(), "a miss must record nothing");
let legacy = object!{ live_boost: 1 };
let end_body = object!{ token: "9.no-record" };
assert!(recorded_privacy(Some(&legacy), &end_body), "falls back to the live room");
close_room(room);
assert!(!recorded_privacy(Some(&legacy), &end_body), "and to public once it is gone");
}
#[test]
fn player_count_does_not_double_count_the_poster() {
let body = object!{

View File

@@ -58,13 +58,6 @@ pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(90);
const PROP_ROOM_LEVEL: &str = "C0";
const PROP_ROOM_POWER: &str = "C1";
// MultiPlayProtocol.RoomConst.Token — "{userId}.{guid}", minted by the master client in
// MultiEventMatchingScene right before the live starts (CommitCurrentRoomToken +
// PushCurrentRoomData) and mirrored to the whole party. Every member posts it as the
// `token` field of /multi_live/start|end, so it is the only handle the HTTP side has on
// which room a finishing live belongs to. The relay never writes it, only reads it back.
const PROP_ROOM_TOKEN: &str = "C";
// Photon's well-known room properties are BYTE keys living in the same bag as the
// game's string keys; the client transport encodes a byte key as "#" + decimal.
// GamePropertyKey.IsOpen is 253 and IsVisible is 254, and the surviving Photon.Realtime
@@ -133,18 +126,6 @@ struct Room {
max_players: u8,
visible: bool,
open: bool,
// Whether this room was created as a PRIVATE (join-by-code) party, latched once at
// creation and never touched again. /multi_live/end branches the score-board write on
// it, so it must NOT be re-derived from `visible` later: the game hides a room the
// moment its members are decided (MultiMatchingView.SetRoomOpenVisible(false)), which
// makes a public room mid-live indistinguishable from a private one by the live flags.
//
// The signal is the creation-time visibility, because that is exactly what separates
// the client's two create paths: MultiPlayManager.CreatePublicRoom issues
// CreateRoom("", 4, visible: true, open: true) and lets the server name the room, while
// CreatePrivateRoom issues CreateRoom(code, 4, visible: false, open: true) — a private
// party is by definition the one that is never listed for random matching.
private: bool,
props: Map,
// Kept only so a future lobby-listing op can honour what the creator asked to
// publish; the matcher reads C0/C1 straight off the room bag like Photon's SQL did.
@@ -431,15 +412,12 @@ impl Registry {
// disagree the bag wins, because the bag is what every client polls.
let (mut visible, mut open) = (visible, open);
apply_flag_props(&props, &mut visible, &mut open);
// Latched here, from the settled creation-time visibility, and never updated.
let private = !visible;
// The creator is master and takes actor 1.
let room = Room {
lobby,
max_players,
visible,
open,
private,
props,
lobby_prop_keys,
// The creator has nobody to notify, but its bag is seeded from the op-4
@@ -916,26 +894,6 @@ impl Registry {
// --- introspection (tests, and a future webui panel) ----------------------
// Whether the room carrying this live token is a private (join-by-code) party, or None
// when no live room claims the token.
//
// The token is matched against the ROOM bag rather than against the poster's account:
// all up to four party members post the same token, and only the master ever wrote it,
// so the bag is the one place the HTTP and relay halves meet. Room names are globally
// unique but a token is not addressable by name, so this is a scan — the registry holds
// at most a handful of live rooms and this runs once per /multi_live/end.
pub fn token_room_is_private(&self, token: &str) -> Option<bool> {
if token.is_empty() {
// An unset room prop reads back as "" on the client, which would otherwise
// match every room that never had a token pushed.
return None;
}
self.rooms
.values()
.find(|room| room.props.get_str(PROP_ROOM_TOKEN) == Some(token))
.map(|room| room.private)
}
#[allow(dead_code)]
pub fn room_count(&self) -> usize {
self.rooms.len()
@@ -947,24 +905,8 @@ impl Registry {
}
}
// What /multi_live/start asks, while the room is certainly still alive: is this a private
// party, or does no live room claim the token at all? The distinction matters to the
// caller — an answer is recorded on the start record, a miss is not (see
// multi_live::record_room_privacy).
pub fn token_room_privacy(token: &str) -> Option<bool> {
registry().token_room_is_private(token)
}
// The same question with the miss folded away, for a start record from before the answer
// was recorded at start time.
//
// A token no live room claims answers `false`. The room is torn down as soon as its last
// active player leaves cleanly, and an end can arrive after that (a client that quit the
// room before its POST landed, or a server restart), so "unknown" has to resolve to
// something — and public is the official behaviour, which is the safe side to be wrong on.
pub fn token_is_private_room(token: &str) -> bool {
token_room_privacy(token).unwrap_or(false)
}
// This file is like 1.5k lines of tests :skull:
#[cfg(test)]
mod tests {
@@ -2300,88 +2242,6 @@ mod tests {
}
}
// --- live-token lookup (the /multi_live/end score-board branch) -----------------
//
// The end handler has nothing but the room token to go on, and has to tell a private
// party from public matchmaking with it. The trap is that the live flags cannot answer
// the question: the game hides a PUBLIC room as well, the moment its members are
// decided, so privacy is latched at creation instead.
// MultiPlayManager.CreatePrivateRoom: the 6-digit code is the room name, and the room
// is created hidden so random matching can never land in it.
fn create_private(h: &mut Harness, id: ConnId, code: &str) {
h.send(id, ClientMsg::CreateRoom {
name: code.to_string(),
max_players: 4,
visible: false,
open: true,
props: Map::new(),
lobby_prop_keys: vec![],
player_props: Map::new(),
});
}
// MultiEventMatchingScene: CommitCurrentRoomToken then PushCurrentRoomData, once, just
// before the live starts.
fn push_token(h: &mut Harness, id: ConnId, token: &str) {
h.send(id, ClientMsg::SetRoomProps {
props: Map::from_pairs(vec![(PROP_ROOM_TOKEN, Value::Str(token.to_string()))]),
});
}
#[test]
fn a_private_room_is_found_by_the_live_token_it_carries() {
let mut h = Harness::new();
let a = h.connect(1);
let b = h.connect(2);
create_private(&mut h, a, "042719");
h.send(b, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() });
push_token(&mut h, a, "1.abcd");
// Every party member posts this same token, so both ends resolve to the one room.
assert_eq!(h.reg.token_room_is_private("1.abcd"), Some(true));
}
#[test]
fn a_public_room_stays_public_after_the_game_hides_it() {
// The regression this whole field exists for: MultiMatchingView.SetRoomOpenVisible
// (false) closes and hides a public room once its members are decided, so by the
// time the live ends the current flags look exactly like a private room's.
let mut h = Harness::new();
let a = h.connect(1);
h.create(a, "1234567", vec![]);
push_token(&mut h, a, "1.efgh");
assert_eq!(h.reg.token_room_is_private("1.efgh"), Some(false));
set_flags(&mut h, a, vec![
(PROP_ROOM_IS_OPEN, Value::Bool(false)),
(PROP_ROOM_IS_VISIBLE, Value::Bool(false)),
]);
assert_eq!(
h.reg.token_room_is_private("1.efgh"),
Some(false),
"a hidden public room must not be mistaken for a private party"
);
}
#[test]
fn an_unknown_or_empty_token_matches_no_room() {
let mut h = Harness::new();
let a = h.connect(1);
create_private(&mut h, a, "042719");
push_token(&mut h, a, "1.abcd");
assert_eq!(h.reg.token_room_is_private("2.nope"), None);
// A room whose master never pushed a token reads back "" on the client; that must
// not match the first room in the registry.
assert_eq!(h.reg.token_room_is_private(""), None);
// And once the last active player leaves, the room — and the answer — is gone.
h.send(a, ClientMsg::LeaveRoom);
assert_eq!(h.reg.room_count(), 0);
assert_eq!(h.reg.token_room_is_private("1.abcd"), None);
}
#[test]
fn disconnect_drops_the_connection_record() {
let mut h = Harness::new();

View File

@@ -67,8 +67,7 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
}
}
// Runtime custom cards are unresolvable below protocol 3. start.rs blocks
// flagged accounts on old clients, so this is belt-and-braces
// Don't allow account downgrade (will crash client)
if !crate::router::custom_card::client_supports(&req) {
crate::router::custom_card::strip_unsupported(&mut user);
}
@@ -182,9 +181,10 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
pub async fn announcement(Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc_home(&key);
user["home"]["new_announcement_flag"] = (0).into();
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
userdata::save_acc_home(&key, user);
Api(Some(object!{

View File

@@ -38,6 +38,12 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
object!{success: false}
}
// Used by gree
pub fn transfer_code_exists(token: &str) -> bool {
let database = userdata::get_userdata_database();
database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok()
}
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
let database = userdata::get_userdata_database();
let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {

View File

@@ -1,6 +1,513 @@
use actix_web::{HttpResponse, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
use actix_multipart::Multipart;
use futures_util::TryStreamExt;
use jzon::{array, object, JsonValue};
use include_dir::{include_dir, Dir};
use std::collections::{HashMap, HashSet};
pub fn announcement(_req: HttpRequest) -> HttpResponse {
HttpResponse::Ok().body("sif2 is back!")
use crate::router::{global, userdata, webui};
use crate::database::{announcements, permissions};
use crate::database::announcements::Banner;
static ASSETS: Dir<'_> = include_dir!("web_assets/announcement/");
const MAX_BANNER_BYTES: usize = 8 * 1024 * 1024;
const MAX_BANNER_DIM: u32 = 8192;
const MAX_SCALED_PIXELS: u64 = 16 * 1024 * 1024;
const BANNER_W: u32 = 420;
const BANNER_H: u32 = 168;
const CATEGORY_LABELS: &[(i64, &str, &str)] = &[
(1, "notice", "お知らせ"),
(2, "update", "アップデート"),
(3, "bug", "不具合")
];
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/web/announcement")
.route("", web::get().to(list))
.route("/detail", web::get().to(detail))
.route("/bulkRead", web::get().to(bulk_read))
.route("/assets/{file}", web::get().to(asset))
.route("/banner/{id}", web::get().to(banner_image))
);
cfg.service(
web::scope("/announcement")
.route("/list", web::get().to(admin_list))
.route("/create", web::post().to(create))
.route("/update", web::post().to(update))
.route("/delete", web::post().to(delete))
.route("/banner/{id}", web::get().to(admin_banner_image))
);
}
fn disabled() -> bool {
crate::get_args().hidden
}
fn query_i64(req: &HttpRequest, key: &str, def: i64) -> i64 {
req.query_string()
.split('&')
.find(|s| s.starts_with(&format!("{key}=")))
.and_then(|s| s.split('=').nth(1))
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(def)
}
fn player_key(req: &HttpRequest) -> Option<String> {
let key = global::get_login(req.headers(), "");
if key.is_empty() { None } else { Some(key) }
}
fn read_set(req: &HttpRequest) -> HashSet<i64> {
match player_key(req) {
Some(key) => { println!("Player has key"); userdata::get_acc_home(&key)["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect()},
None => { println!("No player key"); HashSet::new() }
}
}
fn mark_read(key: &str, ids: &[i64]) {
let mut user = userdata::get_acc_home(key);
let mut set: HashSet<i64> = user["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect();
for id in ids {
set.insert(*id);
}
let mut sorted: Vec<i64> = set.into_iter().collect();
sorted.sort();
let mut arr = array![];
for id in sorted {
arr.push(id).unwrap();
}
user["home"]["read_announcement_ids"] = arr;
userdata::save_acc_home(key, user);
}
fn display_date(published_at: i64) -> String {
if published_at <= 0 {
return String::new();
}
let s = global::format_datetime(published_at as u64);
format!("{}/{}/{} {}", &s[0..4], &s[5..7], &s[8..10], &s[11..16])
}
fn page_head() -> String {
String::from(r#"<!DOCTYPE html>n<html lang="ja-JP"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=320, initial-scale=1.0, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/web/announcement/assets/sanitize.css" type="text/css">
<link rel="stylesheet" href="/web/announcement/assets/news_common.css" type="text/css">
<style>#tab div.on{background-image:none;background-color:#f93981;border-radius:0.6vw;}#news_list hr,#detail hr{background-image:none;background-color:#dfe6e6;}</style>
<script src="/web/announcement/assets/jquery-3.6.0.min.js"></script>
<script>var clicked=false;$(function(){$("a.once").on('click',function(){if(clicked){return false;}clicked=true;return true;});});window.addEventListener('pageshow',function(e){if(e.persisted){clicked=false;}});</script>
</head><body>"#)
}
fn page_foot() -> String {
String::from("</body></html>")
}
fn tab_bar(active: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut tabs = String::new();
for (cat, class, label) in CATEGORY_LABELS {
let unread = !bulk && announcements::visible_ids(Some(*cat)).iter().any(|id| !read.contains(id));
let badge = if unread {
String::from("<span class=\"new\"><img class=\"bg_badge_eff\" src=\"/web/announcement/assets/news_bg_badge_eff.png\"><img class=\"bg_badge\" src=\"/web/announcement/assets/news_bg_badge.png\"></span>")
} else {
String::new()
};
let inner = if *cat == active {
format!("<div class=\"on\">{label}</div>")
} else {
format!("<a class=\"once\" href=\"/web/announcement?category={cat}\">{label}</a>")
};
tabs.push_str(&format!("<div class=\"{class}\">{badge}{inner}</div>"));
}
let read_btn = if bulk {
String::from("<div class=\"read_btn\"><img class=\"off\" src=\"/web/announcement/assets/news_btn_bulk.png\"></div>")
} else {
format!("<div class=\"read_btn\"><a class=\"once\" href=\"/web/announcement/bulkRead?category={active}&amp;page=1\"><img src=\"/web/announcement/assets/news_btn_bulk.png\"></a></div>")
};
format!("<div id=\"header\"><div id=\"tab\">{tabs}{read_btn}</div></div><div id=\"tab_bottom\"></div>")
}
fn render_list(category: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut list = String::new();
let items = announcements::list_category(category);
if items.is_empty() {
list.push_str(&format!(r#"
<div class="info_title"><span class="title_text">No announcements right now</span></div>
"#));
}
for item in items.members() {
let id = item["id"].as_i64().unwrap_or(0);
let banner_url = if item["has_banner"].as_bool().unwrap_or(false) {
format!("/web/announcement/banner/{id}.png")
} else {
String::from("/web/announcement/assets/news_banner_generic_news.png")
};
let kind = item["type"].as_str().unwrap_or("news");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let update_text = if item["updated"].as_bool().unwrap_or(false) { "<span class=\"update_text\">- update</span>" } else { "" };
let new_badge = if !bulk && !read.contains(&id) {
String::from("<div class=\"info_new_image\"><img class=\"new\" src=\"/web/announcement/assets/news_icon_new.png\"></div>")
} else {
String::new()
};
let title = item["title"].as_str().unwrap_or("");
list.push_str(&format!(r#"
<li class="list_area"><div class="information_area"><div id="{id}" class="anchor"></div>
<a href="/web/announcement/detail?announcement_id={id}&amp;page=1" class="news">
<div class="main_image"><span class="banner"><img src="{banner_url}"></span></div>
<div class="information">
<div class="info_type_image"><img class="tag" src="/web/announcement/assets/news_icon_{kind}.png"></div>
<div class="info_date"><span class="date_text">{date}</span>{update_text}</div>
{new_badge}
<div class="clear"></div>
<div class="info_title"><span class="title_text">{title}</span></div>
</div>
<div class="arrow_image"><img class="arrow" src="/web/announcement/assets/news_img_arrow.png"></div>
</a></div><div class="clear"></div><hr></li>
"#));
}
format!("{}{}<div id=\"news_list\"><ul>{}</ul><div id=\"page_area\"><div id=\"paging\"><span class=\"page off\">1</span></div></div></div>{}",
page_head(), tab_bar(category, read, bulk), list, page_foot())
}
fn render_detail(item: &JsonValue, read: &HashSet<i64>) -> String {
let category = item["category"].as_i64().unwrap_or(1);
let title = item["title"].as_str().unwrap_or("");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let body = item["body"].as_str().unwrap_or("");
let banner = if item["has_banner"].as_bool().unwrap_or(false) {
let id = item["id"].as_i64().unwrap_or(0);
format!("<div class=\"detail_image\" style=\"display:block\"><img src=\"/web/announcement/banner/{id}.png\"></div>")
} else {
String::new()
};
format!("{}{}<div id=\"detail\"><div class=\"detail_text\"><div class=\"title\">{title}</div><div class=\"date\">{date}</div>{banner}<hr class=\"hr_detail\">{body}</div></div>{}",
page_head(), tab_bar(category, read, false), page_foot())
}
fn html(body: String) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::html())
.body(body)
}
fn redirect_list(category: i64) -> HttpResponse {
HttpResponse::Found()
.insert_header(("Location", format!("/web/announcement?category={category}")))
.body("")
}
async fn list(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
html(render_list(category, &read_set(&req), false))
}
async fn detail(req: HttpRequest) -> HttpResponse {
let id = query_i64(&req, "announcement_id", 0);
let Some(item) = announcements::get(id) else {
return redirect_list(1);
};
if !item["visible"].as_bool().unwrap_or(false) {
return redirect_list(item["category"].as_i64().unwrap_or(1));
}
if let Some(key) = player_key(&req) {
mark_read(&key, &[id]);
}
html(render_detail(&item, &read_set(&req)))
}
async fn bulk_read(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
if let Some(key) = player_key(&req) {
mark_read(&key, &announcements::visible_ids(Some(category)));
}
html(render_list(category, &read_set(&req), true))
}
async fn asset(req: HttpRequest) -> HttpResponse {
let file = req.match_info().get("file").unwrap_or("");
let Some(file) = ASSETS.get_file(file) else {
return HttpResponse::NotFound().finish();
};
let body = file.contents();
let mime = mime_guess::from_path(file.path()).first_or_octet_stream();
HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body)
}
fn png_response(bytes: Option<Vec<u8>>) -> HttpResponse {
match bytes {
Some(bytes) => HttpResponse::Ok()
.insert_header(ContentType::png())
.insert_header(("content-length", bytes.len()))
.body(bytes),
None => HttpResponse::NotFound().finish()
}
}
fn banner_id(req: &HttpRequest) -> i64 {
req.match_info().get("id").unwrap_or("").trim_end_matches(".png").parse::<i64>().unwrap_or(0)
}
async fn banner_image(req: HttpRequest) -> HttpResponse {
png_response(announcements::get_public_banner(banner_id(&req)))
}
async fn admin_banner_image(req: HttpRequest) -> HttpResponse {
if disabled() {
return HttpResponse::NotFound().finish();
}
if manager_uid(&req).filter(|uid| permissions::has(*uid, permissions::ANNOUNCEMENT_MANAGE)).is_none() {
return HttpResponse::NotFound().finish();
}
png_response(announcements::get_banner(banner_id(&req)))
}
type Fields = HashMap<String, Vec<u8>>;
async fn read_multipart(mut payload: Multipart) -> Result<Fields, String> {
let mut fields = Fields::new();
let mut total = 0usize;
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
let name = field.name().unwrap_or("").to_string();
let mut data = Vec::new();
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
total += chunk.len();
if total > MAX_BANNER_BYTES {
return Err(format!("Upload exceeds the {} MB limit", MAX_BANNER_BYTES / (1024 * 1024)));
}
data.extend_from_slice(&chunk);
}
fields.insert(name, data);
}
Ok(fields)
}
fn field_str(fields: &Fields, key: &str) -> String {
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
}
fn field_flag(fields: &Fields, key: &str) -> bool {
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
}
fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec<u8>> {
fields.get(key).filter(|v| !v.is_empty())
}
fn process_banner(bytes: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(bytes).map_err(|_| String::from("The banner is not a decodable image (png, jpg and webp work)"))?;
if img.width() > MAX_BANNER_DIM || img.height() > MAX_BANNER_DIM {
return Err(format!("The banner is {}x{} - neither side may exceed {}px", img.width(), img.height(), MAX_BANNER_DIM));
}
let img = img.to_rgba8();
let scale = f64::max(BANNER_W as f64 / img.width() as f64, BANNER_H as f64 / img.height() as f64);
let scaled_w = ((img.width() as f64 * scale).round() as u32).max(1);
let scaled_h = ((img.height() as f64 * scale).round() as u32).max(1);
if (scaled_w as u64) * (scaled_h as u64) > MAX_SCALED_PIXELS {
return Err(format!("The banner is too far from the {}x{} banner shape to be cropped", BANNER_W, BANNER_H));
}
let scaled = image::imageops::resize(&img, scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
let x = (scaled.width() - BANNER_W.min(scaled.width())) / 2;
let y = (scaled.height() - BANNER_H.min(scaled.height())) / 2;
let cropped = image::imageops::crop_imm(&scaled, x, y, BANNER_W, BANNER_H).to_image();
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(cropped).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(rv)
}
fn send_json(resp: JsonValue) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
fn manager_uid(req: &HttpRequest) -> Option<i64> {
let token = webui::get_login_token(req)?;
let login_token = userdata::webui_login_token(&token)?;
userdata::get_acc(&login_token)["user"]["id"].as_i64()
}
fn require_manager(req: &HttpRequest) -> Result<i64, HttpResponse> {
if disabled() {
return Err(HttpResponse::NotFound().finish());
}
let Some(uid) = manager_uid(req) else {
return Err(webui::error("Not logged in"));
};
if !permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) {
return Err(webui::error("You do not have permission to manage announcements"));
}
Ok(uid)
}
async fn admin_list(req: HttpRequest) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let mut categories = array![];
for (id, key, label) in CATEGORY_LABELS {
categories.push(object!{ id: *id, key: *key, label: *label }).unwrap();
}
let mut types = array![];
for kind in announcements::TYPES {
types.push(*kind).unwrap();
}
send_json(object!{
result: "OK",
data: {
announcements: announcements::get_all(),
categories: categories,
types: types
}
})
}
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
let uid = match require_manager(&req) {
Ok(uid) => uid,
Err(resp) => return resp
};
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_new(uid, &fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn published_at_of(raw: &str) -> i64 {
if raw.is_empty() {
global::timestamp() as i64
} else {
global::parse_datetime(raw).map(|t| t as i64).unwrap_or_else(|| global::timestamp() as i64)
}
}
fn save_new(uid: i64, fields: &Fields) -> Result<i64, String> {
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = match file_of(fields, "banner") {
Some(bytes) => Some(process_banner(bytes)?),
None => None
};
Ok(announcements::create(category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at_of(&field_str(fields, "published_at")), uid))
}
async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_update(&fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn save_update(fields: &Fields) -> Result<i64, String> {
let id = field_str(fields, "id").parse::<i64>().unwrap_or(0);
let Some(stored) = announcements::get(id) else {
return Err(String::from("That announcement no longer exists"));
};
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = if let Some(bytes) = file_of(fields, "banner") {
Banner::Set(process_banner(bytes)?)
} else if field_flag(fields, "remove_banner") {
Banner::Clear
} else {
Banner::Keep
};
let published_at = if field_str(fields, "published_at").is_empty() {
stored["published_at"].as_i64().unwrap_or_else(|| global::timestamp() as i64)
} else {
published_at_of(&field_str(fields, "published_at"))
};
announcements::update(id, category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at);
Ok(id)
}
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let body = jzon::parse(&body).unwrap_or(object!{});
let id = body["id"].as_i64().unwrap_or(0);
if announcements::get(id).is_none() {
return webui::error("That announcement no longer exists");
}
announcements::delete(id);
send_json(object!{ result: "OK" })
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([120, 90, 200, 255]));
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
rv
}
#[test]
fn banners_are_cropped_to_the_official_size() {
for (w, h) in [(420, 168), (1200, 300), (300, 1200), (64, 64)] {
let out = process_banner(&png(w, h)).unwrap();
let decoded = image::load_from_memory(&out).unwrap();
assert_eq!((decoded.width(), decoded.height()), (BANNER_W, BANNER_H), "source {}x{}", w, h);
}
assert!(process_banner(b"not an image").unwrap_err().contains("not a decodable image"));
}
#[test]
fn extreme_sources_are_refused_before_the_resize_allocates() {
let err = process_banner(&png(9000, 32)).unwrap_err();
assert!(err.contains("9000x32"), "got {}", err);
let err = process_banner(&png(24, 6000)).unwrap_err();
assert!(err.contains("banner shape"), "got {}", err);
}
}

View File

@@ -367,9 +367,6 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
}
lazy_static! {
// The selectable character list for the custom-card form: every official
// and SIF1-imported character in the baked csv, by name. The import band
// starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014
static ref CHARACTER_CHOICES: JsonValue = {
let mut rv = jzon::array![];
for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
@@ -384,8 +381,6 @@ lazy_static! {
rv
};
// The skill_center table with its display strings, for picking a center
// skill by name instead of by raw id
static ref SKILL_CENTER_CHOICES: JsonValue = {
let mut en_rows = object!{};
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
@@ -406,9 +401,6 @@ lazy_static! {
};
}
// The characters a card upload may reference, for the webui's searchable
// picker: the baked official + imported list, plus the custom characters
// this session may build on (their own and the publicly visible ones)
pub fn list_characters(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -446,8 +438,6 @@ pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp))
}
// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array
// lengths) so the form enforces them before submitting
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
if session_uid(&req).is_none() {
return error("Not logged in");
@@ -461,8 +451,6 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp))
}
// The requesting user's own effective scopes, for webui nav gating. Any
// session may ask - it only ever reveals what the user themselves holds
pub fn my_scopes(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -471,12 +459,13 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
result: "OK",
data: {
uid: uid,
scopes: permissions::scopes_for(uid),
scopes: permissions::get_user_permissions(uid),
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|| permissions::has(uid, permissions::PERMISSION_REVOKE)
|| permissions::has(uid, permissions::PERMISSION_REVOKE),
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE)
}
};
HttpResponse::Ok()
@@ -484,8 +473,6 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp))
}
// The admin view: every grant plus the grantable vocabulary. Needs a
// permission.* scope - my_scopes is the anyone-can-ask endpoint
pub fn list_permissions(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -505,7 +492,7 @@ pub fn list_permissions(req: HttpRequest) -> HttpResponse {
uid: uid,
can_grant: can_grant,
can_revoke: can_revoke,
scopes: permissions::scopes_for(uid),
scopes: permissions::get_user_permissions(uid),
available: available,
grants: permissions::grants()
}
@@ -592,6 +579,11 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.body(jzon::stringify(resp))
}
// 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::*;