Announcements working
@@ -2,3 +2,4 @@ pub mod gree;
|
||||
pub mod custom_song;
|
||||
pub mod custom_card;
|
||||
pub mod permissions;
|
||||
pub mod announcements;
|
||||
|
||||
267
src/database/announcements.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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!{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}&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}&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
2
web_assets/announcement/jquery-3.6.0.min.js
vendored
Normal file
BIN
web_assets/announcement/news_banner_generic_news.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_bg_badge.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
web_assets/announcement/news_bg_badge_eff.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
web_assets/announcement/news_btn_bulk.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
465
web_assets/announcement/news_common.css
Normal file
@@ -0,0 +1,465 @@
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
背景設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
|
||||
html {
|
||||
box-sizing: border-box; /* 1 */
|
||||
cursor: default; /* 2 */
|
||||
line-height: 1.5; /* 3 */
|
||||
-ms-text-size-adjust: 100%; /* 4 */
|
||||
-webkit-text-size-adjust: 100%; /* 5 */
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
/* 表示無い個所の処理 */
|
||||
background-repeat: no-repeat; /* 1 */
|
||||
box-sizing: inherit; /* 2 */
|
||||
}
|
||||
body {
|
||||
font-family: 'YuGothic','Yu Gothic','Hiragino Kaku Gothic ProN','ヒラギノ角ゴ ProN W3',sans-serif;
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
color: #494949;
|
||||
background-color: #f5ffff;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
-webkit-touch-callout: none; /* リンク長押しのポップアップを無効化*/
|
||||
-webkit-user-select: none; /* テキスト長押しの選択ボックスを無効化*/
|
||||
-webkit-tap-highlight-color:rgba(0,0,0,0);/*リンクの領域表示を無効化*/
|
||||
}
|
||||
|
||||
/* ダークモード */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #f5ffff;
|
||||
color: #494949;
|
||||
}
|
||||
#header {
|
||||
background-color: #f5ffff;
|
||||
}
|
||||
#tab div.on {
|
||||
color: #f5ffff;
|
||||
}
|
||||
.detail_text {
|
||||
color: #494949;
|
||||
}
|
||||
#tab div a, #tab div a:visited {
|
||||
color: #494949;
|
||||
}
|
||||
#news_list .news .info_date {
|
||||
color: #828282;
|
||||
}
|
||||
}
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
文字大きさ、ポジション 幅1580px基準のvw
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
/* 詳細本文 */
|
||||
/* 40文字×無制限 */
|
||||
.detail_text {
|
||||
font-size: 2.0vw;
|
||||
color: #494949;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 装飾用 */
|
||||
/* <div class="title"> */
|
||||
#detail .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* <span class="bold"> */
|
||||
#detail .bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* <div class="date"> */
|
||||
#detail .date {
|
||||
color: #f93981;
|
||||
}
|
||||
|
||||
/* <span class="pink"> */
|
||||
#detail .pink {
|
||||
color: #f93981;
|
||||
}
|
||||
|
||||
/* 見出し用 *
|
||||
/* 更新日 24px */
|
||||
.date_text {
|
||||
font-size: 1.6vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* タイトル 32px */
|
||||
.title_text {
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.update_text {
|
||||
font-size: 1.3vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#tab {
|
||||
font-size: 2.0vw;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
メニュータブ
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
#header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 7.2vw;
|
||||
background-color: #F2FFFF;
|
||||
z-index: 1;
|
||||
}
|
||||
#tab {
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin: 1.6vw 1.3vw;
|
||||
width: 97.4vw;
|
||||
height: 4.0vw;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#tab>div {
|
||||
float: left;
|
||||
width: 18.9vw;
|
||||
height: 4.0vw;
|
||||
color: #494949;
|
||||
text-align: center;
|
||||
line-height: 4.0vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#tab div:nth-child(1), #tab div:nth-child(2) {
|
||||
border-right: #CDD0D0 1px solid;
|
||||
}
|
||||
|
||||
#tab div a, #tab div a:visited {
|
||||
color: #494949;
|
||||
}
|
||||
|
||||
#tab div.on {
|
||||
color: #f5ffff;
|
||||
background-image: url("../0_common_images/news_img_tab.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
#tab a {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: 15.5vw;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#tab .tab_text {
|
||||
text-align: center;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
#tab .new img.bg_badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: -0.5vw;
|
||||
right: 1.1vw;
|
||||
width: 2.4vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab .new img.bg_badge_eff {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -1.3vw;
|
||||
right: 0.3vw;
|
||||
width: 4.0vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab>div.read_btn, #tab>div.more_btn {
|
||||
float: right;
|
||||
width: 15.6vw;
|
||||
height: 4.0vw;
|
||||
margin: 0vw -0.4vw 0vw 0.4vw;
|
||||
}
|
||||
|
||||
#tab .read_btn img, #tab .more_btn img {
|
||||
width: 15.6vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#tab img.off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#tab_bottom {
|
||||
margin-top: 7.2vw;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
|
||||
共通項目
|
||||
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
|
||||
/*種類アイコン画像*/
|
||||
.info_type_image img.tag {
|
||||
width : 13.7vw; /* 画像を枠の100%の横幅にする */
|
||||
height: auto; /* 画像の縦幅を自動調整 */
|
||||
}
|
||||
|
||||
.info_type_image img.present {
|
||||
width : 1.5vw;
|
||||
height: auto;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
|
||||
.information_area {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.information {
|
||||
position: relative;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.information img.new {
|
||||
width : 8.3vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
img.arrow {
|
||||
width : 1.8vw;
|
||||
height: auto;
|
||||
margin-top: 2.4vw;
|
||||
margin-left: 1.0vw;
|
||||
margin-right: 0.4vw;
|
||||
}
|
||||
|
||||
img.arrow_back {
|
||||
width : 1.8vw;
|
||||
height: auto;
|
||||
margin-top: 2.4vw;
|
||||
margin-right: 1.4vw;
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
.info_title {
|
||||
margin-top: 0vw;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
position: absolute;
|
||||
margin-top: -7.2vw;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#outer {
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
}
|
||||
|
||||
#outer #center {
|
||||
height:100%;
|
||||
width:100%;
|
||||
display:table;
|
||||
}
|
||||
|
||||
#outer #center p {
|
||||
display: table-cell;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
お知らせリスト設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#news_list .main_image img {
|
||||
width : 20.86vw;
|
||||
height: 8.34vw;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#news_list .news {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
color: #494949;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);/*リンクの領域表示を無効化*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#news_list .news .info_date {
|
||||
color: #828282;
|
||||
}
|
||||
|
||||
#news_list ul {
|
||||
margin: 1.3vw 1.3vw 0;
|
||||
padding: 0;
|
||||
width: 97.4vw;
|
||||
}
|
||||
|
||||
#news_list li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#news_list hr, #detail hr {
|
||||
background-image: url("../0_common_images/news_img_line.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: contain;
|
||||
height: 0.26vw;
|
||||
border: 0;
|
||||
margin-top: 0.8vw;
|
||||
margin-bottom: 0.8vw;
|
||||
}
|
||||
|
||||
#news_list hr.hr_detail {
|
||||
margin-top: 1.1vw;
|
||||
margin-bottom: 1.1vw;
|
||||
|
||||
}
|
||||
|
||||
#news_list .list_area {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#news_list .information {
|
||||
float: left;
|
||||
margin-left: 2.0vw;
|
||||
width: 70.5vw;
|
||||
}
|
||||
|
||||
#news_list .info_type_image {
|
||||
float: left;
|
||||
margin-top: 0.8vw;
|
||||
width: 13.7vw;
|
||||
}
|
||||
|
||||
#news_list .info_date {
|
||||
float: left;
|
||||
margin-top: 1.0vw;
|
||||
margin-left: 2.0vw;
|
||||
}
|
||||
|
||||
#news_list .info_new_image {
|
||||
float: right;
|
||||
margin-top: 0.7vw;
|
||||
width: 8.3vw;
|
||||
}
|
||||
|
||||
#news_list .arrow_image {
|
||||
float: left;
|
||||
width: 3.2vw;
|
||||
height: 8.3vw;
|
||||
}
|
||||
|
||||
#page_area {
|
||||
margin-top: 3%;
|
||||
padding: 0 2.0vw 4.0vw 2.0vw;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
font-size: 1.2vw;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
#paging {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#page_area .page {
|
||||
font-size: 1.8vw;
|
||||
font-weight: bold;
|
||||
padding-left: 1.5vw;
|
||||
padding-right: 1.5vw;
|
||||
color: #191919;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#page_area .page.new {
|
||||
color: #c12424;
|
||||
}
|
||||
|
||||
#page_area .page.off {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
詳細ページ設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#detail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#detail .detail_text {
|
||||
width : 97.4vw;
|
||||
height : auto;
|
||||
vertical-align:middle;
|
||||
padding: 0;
|
||||
margin: 0vw 1.3vw 1.3vw;
|
||||
}
|
||||
|
||||
#detail .detail_image {
|
||||
width: 100%;
|
||||
padding: 1.0vw;
|
||||
text-align: center;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
#detail .detail_image img {
|
||||
width: 45.0vw;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.detail_text img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
外部コンテンツページ設定
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
|
||||
#contents {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#contents #banner_list {
|
||||
position: relative;
|
||||
left: 3vw;
|
||||
}
|
||||
|
||||
#contents .banner {
|
||||
float: left;
|
||||
padding: 0.6vw;
|
||||
}
|
||||
|
||||
#contents .banner:nth-child(3n+4) {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#contents .clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#contents img {
|
||||
width: 30.0vw;
|
||||
height: auto;
|
||||
}
|
||||
BIN
web_assets/announcement/news_icon_event.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
web_assets/announcement/news_icon_gacha.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_icon_maintenance.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web_assets/announcement/news_icon_new.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web_assets/announcement/news_icon_news.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
web_assets/announcement/news_icon_others.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
web_assets/announcement/news_icon_shop.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
web_assets/announcement/news_img_arrow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
550
web_assets/announcement/sanitize.css
Normal file
@@ -0,0 +1,550 @@
|
||||
/*! sanitize.css v4.0.0 | CC0 License | github.com/10up/sanitize.css */
|
||||
|
||||
/* Display definitions
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
* 1. Add the correct display in Edge, IE, and Firefox.
|
||||
* 2. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
article,
|
||||
aside,
|
||||
details, /* 1 */
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
main, /* 2 */
|
||||
menu,
|
||||
nav,
|
||||
section,
|
||||
summary { /* 1 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 9-.
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
progress,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in iOS 4-7.
|
||||
*/
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10-.
|
||||
* 1. Add the correct display in IE.
|
||||
*/
|
||||
|
||||
template, /* 1 */
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Elements of HTML (https://www.w3.org/TR/html5/semantics.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove repeating backgrounds in all browsers (opinionated).
|
||||
* 2. Add box sizing inheritence in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
background-repeat: no-repeat; /* 1 */
|
||||
box-sizing: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add text decoration inheritance in all browsers (opinionated).
|
||||
* 2. Add vertical alignment inheritence in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
::before,
|
||||
::after {
|
||||
text-decoration: inherit; /* 1 */
|
||||
vertical-align: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add border box sizing in all browsers (opinionated).
|
||||
* 2. Add the default cursor in all browsers (opinionated).
|
||||
* 3. Add a flattened line height in all browsers (opinionated).
|
||||
* 4. Prevent font size adjustments after orientation changes in IE and iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
box-sizing: border-box; /* 1 */
|
||||
cursor: default; /* 2 */
|
||||
font-family: sans-serif; /* 3 */
|
||||
line-height: 1.5; /* 3 */
|
||||
-ms-text-size-adjust: 100%; /* 4 */
|
||||
-webkit-text-size-adjust: 100%; /* 5 */
|
||||
}
|
||||
|
||||
/* Sections (https://www.w3.org/TR/html5/sections.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font sizes and margins on `h1` elements within
|
||||
* `section` and `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: .67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content (https://www.w3.org/TR/html5/grouping-content.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct font sizing inheritance and scaling in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the height in Firefox.
|
||||
* 2. Add visible overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the list style on navigation lists in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
nav ol,
|
||||
nav ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add a bordered underline effect in all browsers.
|
||||
* 2. Remove text decoration in Firefox 40+.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: 1px dotted; /* 1 */
|
||||
text-decoration: none; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font style in Android 4.3-.
|
||||
*/
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct colors in IE 9-.
|
||||
*/
|
||||
|
||||
mark {
|
||||
background-color: #ffff00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 83.3333%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the positioning on superscript and subscript elements
|
||||
* in all browsers (opinionated).
|
||||
* 1. Correct the font size in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 83.3333%; /* 1 */
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove the text shadow on text selections (opinionated).
|
||||
* 1. Restore the coloring undone by defining the text shadow (opinionated).
|
||||
*/
|
||||
|
||||
::-moz-selection {
|
||||
background-color: #b3d4fc; /* 1 */
|
||||
color: #000000; /* 1 */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #b3d4fc; /* 1 */
|
||||
color: #000000; /* 1 */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Embedded content (https://www.w3.org/TR/html5/embedded-content-0.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Change the alignment on media elements in all browers (opinionated).
|
||||
*/
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
iframe,
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10-.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the fill color to match the text color in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the overflow in IE.
|
||||
*/
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Links (https://www.w3.org/TR/html5/links.html#links)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the gray background on active links in IE 10.
|
||||
* 2. Remove the gaps in underlines in iOS 8+ and Safari 8+.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent; /* 1 */
|
||||
-webkit-text-decoration-skip: objects; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the outline when hovering in all browsers (opinionated.
|
||||
*/
|
||||
|
||||
:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
/* Tabular data (https://www.w3.org/TR/html5/tabular-data.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Remove border spacing in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* transform-style: (https://www.w3.org/TR/html5/forms.html)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Remove the default styling in all browsers (opinionated).
|
||||
* 3. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
/* button, */
|
||||
input,
|
||||
select
|
||||
/* textarea */
|
||||
{
|
||||
background-color: transparent; /* 1 */
|
||||
border-style: none; /* 1 */
|
||||
color: inherit; /* 1 */
|
||||
font-size: 1em; /* 1 */
|
||||
margin: 0; /* 3 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the overflow in IE.
|
||||
* 1. Correct the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent the WebKit bug where (2) destroys native `audio` and `video`
|
||||
* controls in Android 4.
|
||||
* 2. Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
html [type="button"], /* 1 */
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the border, margin, and padding in all browsers.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #c0c0c0;
|
||||
margin: 0 2px;
|
||||
padding: .35em .625em .75em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the vertical scrollbar in IE.
|
||||
* 2. Change the resize direction on textareas in all browsers (opinionated).
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto; /* 1 */
|
||||
resize: vertical; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the padding in IE 10-.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style on increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding and cancel buttons in Chrome and Safari for OS X.
|
||||
*/
|
||||
|
||||
::-webkit-search-cancel-button,
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the text style on placeholders in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
color: inherit;
|
||||
opacity: .54;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* WAI-ARIA (https://www.w3.org/TR/html5/dom.html#wai-aria)
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Change the cursor on busy elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the cursor on control elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the cursor on disabled, not-editable, or otherwise
|
||||
* inoperable elements (opinionated).
|
||||
*/
|
||||
|
||||
[aria-disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* User interaction (https://www.w3.org/TR/html5/editing.html)
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Remove the tapping delay on clickable elements (opinionated).
|
||||
* 1. Remove the tapping delay in IE 10.
|
||||
*/
|
||||
|
||||
a,
|
||||
area,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea,
|
||||
[tabindex] {
|
||||
-ms-touch-action: manipulation; /* 1 */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change the display on visually hidden accessible elements (opinionated).
|
||||
*/
|
||||
|
||||
[hidden][aria-hidden="false"] {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
display: inherit;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
[hidden][aria-hidden="false"]:focus {
|
||||
clip: auto;
|
||||
}
|
||||