From cb40d74c2c78cf0524b2c435ef6836325a6094bc Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Wed, 12 Aug 2026 17:21:52 -0500 Subject: [PATCH] Announcements working --- src/database.rs | 1 + src/database/announcements.rs | 267 +++++++++ src/database/permissions.rs | 67 +-- src/router.rs | 2 +- src/router/gree.rs | 26 +- src/router/home.rs | 4 + src/router/multi_live.rs | 233 +------- src/router/multi_live/rooms.rs | 142 +---- src/router/user.rs | 8 +- src/router/userdata/user/migration.rs | 6 + src/router/web.rs | 515 +++++++++++++++- src/router/webui.rs | 26 +- web_assets/announcement/jquery-3.6.0.min.js | 2 + .../announcement/news_banner_generic_news.png | Bin 0 -> 4589 bytes web_assets/announcement/news_bg_badge.png | Bin 0 -> 1033 bytes web_assets/announcement/news_bg_badge_eff.png | Bin 0 -> 2250 bytes web_assets/announcement/news_btn_bulk.png | Bin 0 -> 10135 bytes web_assets/announcement/news_common.css | 465 +++++++++++++++ web_assets/announcement/news_icon_event.png | Bin 0 -> 3704 bytes web_assets/announcement/news_icon_gacha.png | Bin 0 -> 4648 bytes .../announcement/news_icon_maintenance.png | Bin 0 -> 5596 bytes web_assets/announcement/news_icon_new.png | Bin 0 -> 2788 bytes web_assets/announcement/news_icon_news.png | Bin 0 -> 4086 bytes web_assets/announcement/news_icon_others.png | Bin 0 -> 4888 bytes web_assets/announcement/news_icon_shop.png | Bin 0 -> 3606 bytes web_assets/announcement/news_img_arrow.png | Bin 0 -> 1934 bytes web_assets/announcement/sanitize.css | 550 ++++++++++++++++++ 27 files changed, 1872 insertions(+), 442 deletions(-) create mode 100644 src/database/announcements.rs create mode 100644 web_assets/announcement/jquery-3.6.0.min.js create mode 100644 web_assets/announcement/news_banner_generic_news.png create mode 100644 web_assets/announcement/news_bg_badge.png create mode 100644 web_assets/announcement/news_bg_badge_eff.png create mode 100644 web_assets/announcement/news_btn_bulk.png create mode 100644 web_assets/announcement/news_common.css create mode 100644 web_assets/announcement/news_icon_event.png create mode 100644 web_assets/announcement/news_icon_gacha.png create mode 100644 web_assets/announcement/news_icon_maintenance.png create mode 100644 web_assets/announcement/news_icon_new.png create mode 100644 web_assets/announcement/news_icon_news.png create mode 100644 web_assets/announcement/news_icon_others.png create mode 100644 web_assets/announcement/news_icon_shop.png create mode 100644 web_assets/announcement/news_img_arrow.png create mode 100644 web_assets/announcement/sanitize.css diff --git a/src/database.rs b/src/database.rs index d6d0fa2..97817a2 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2,3 +2,4 @@ pub mod gree; pub mod custom_song; pub mod custom_card; pub mod permissions; +pub mod announcements; diff --git a/src/database/announcements.rs b/src/database/announcements.rs new file mode 100644 index 0000000..3669553 --- /dev/null +++ b/src/database/announcements.rs @@ -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) +} + +fn row_to_json(row: &rusqlite::Row) -> rusqlite::Result { + Ok(object!{ + id: row.get::(0)?, + category: row.get::(1)?, + type: row.get::(2)?, + title: row.get::(3)?, + body: row.get::(4)?, + has_banner: row.get::(5)? != 0, + updated: row.get::(6)? != 0, + visible: row.get::(7)? != 0, + published_at: row.get::(8)?, + created_by: row.get::(9)?, + created_at: row.get::(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 { + 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> { + 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::>(0)).ok() +} + +pub fn get_public_banner(id: i64) -> Option> { + 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::>(0)).ok() +} + +pub fn visible_ids(category: Option) -> Vec { + 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::(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::(0)).unwrap_or(0) +} + +pub fn create(category: i64, kind: &str, title: &str, body: &str, banner: Option>, 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", "

body

", 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")); + } +} diff --git a/src/database/permissions.rs b/src/database/permissions.rs index 0a1491a..7299f4a 100644 --- a/src/database/permissions.rs +++ b/src/database/permissions.rs @@ -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 { +fn has_permission(scope: &str) -> Vec { if scope == ALL { return vec![String::from(ALL)]; } @@ -80,7 +63,7 @@ fn implied_by(scope: &str) -> Vec { rv } -fn held_scopes(user_id: i64) -> Vec { +fn get_permissions(user_id: i64) -> Vec { 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); } } diff --git a/src/router.rs b/src/router.rs index efed3f6..6e7a471 100644 --- a/src/router.rs +++ b/src/router.rs @@ -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); } diff --git a/src/router/gree.rs b/src/router/gree.rs index ef3ab5f..63c3ae4 100644 --- a/src/router/gree.rs +++ b/src/router/gree.rs @@ -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()); diff --git a/src/router/home.rs b/src/router/home.rs index d41861d..41cc38f 100644 --- a/src/router/home.rs +++ b/src/router/home.rs @@ -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(); diff --git a/src/router/multi_live.rs b/src/router/multi_live.rs index bad343a..f61d3df 100644 --- a/src/router/multi_live.rs +++ b/src/router/multi_live.rs @@ -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 = (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 = 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!{ diff --git a/src/router/multi_live/rooms.rs b/src/router/multi_live/rooms.rs index c732c65..71eb7ff 100644 --- a/src/router/multi_live/rooms.rs +++ b/src/router/multi_live/rooms.rs @@ -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 { - 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 { - 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(); diff --git a/src/router/user.rs b/src/router/user.rs index fae5f39..082e926 100644 --- a/src/router/user.rs +++ b/src/router/user.rs @@ -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!{ diff --git a/src/router/userdata/user/migration.rs b/src/router/userdata/user/migration.rs index 30ff15b..b6b3b20 100644 --- a/src/router/userdata/user/migration.rs +++ b/src/router/userdata/user/migration.rs @@ -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)) { diff --git a/src/router/web.rs b/src/router/web.rs index b0f8ba3..e298eab 100644 --- a/src/router/web.rs +++ b/src/router/web.rs @@ -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::().ok()) + .unwrap_or(def) +} + +fn player_key(req: &HttpRequest) -> Option { + let key = global::get_login(req.headers(), ""); + if key.is_empty() { None } else { Some(key) } +} + +fn read_set(req: &HttpRequest) -> HashSet { + 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 = user["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect(); + for id in ids { + set.insert(*id); + } + let mut sorted: Vec = 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#"n + + + + + + + + + + +"#) +} + +fn page_foot() -> String { + String::from("") +} + +fn tab_bar(active: i64, read: &HashSet, 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("") + } else { + String::new() + }; + let inner = if *cat == active { + format!("
{label}
") + } else { + format!("{label}") + }; + tabs.push_str(&format!("
{badge}{inner}
")); + } + let read_btn = if bulk { + String::from("
") + } else { + format!("
") + }; + format!("
{tabs}{read_btn}
") +} + +fn render_list(category: i64, read: &HashSet, bulk: bool) -> String { + let mut list = String::new(); + let items = announcements::list_category(category); + if items.is_empty() { + list.push_str(&format!(r#" +
No announcements right now
+"#)); + } + + 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) { "- update" } else { "" }; + let new_badge = if !bulk && !read.contains(&id) { + String::from("
") + } else { + String::new() + }; + let title = item["title"].as_str().unwrap_or(""); + list.push_str(&format!(r#" +

  • +"#)); + } + + format!("{}{}
      {}
    1
    {}", + page_head(), tab_bar(category, read, bulk), list, page_foot()) +} + +fn render_detail(item: &JsonValue, read: &HashSet) -> 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!("
    ") + } else { + String::new() + }; + format!("{}{}
    {title}
    {date}
    {banner}
    {body}
    {}", + 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>) -> 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::().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>; + +async fn read_multipart(mut payload: Multipart) -> Result { + 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> { + fields.get(key).filter(|v| !v.is_empty()) +} + +fn process_banner(bytes: &[u8]) -> Result, 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 { + 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 { + 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 { + let category = field_str(fields, "category").parse::().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 { + let id = field_str(fields, "id").parse::().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::().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 { + 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); + } } diff --git a/src/router/webui.rs b/src/router/webui.rs index 3b4d833..31c9bfc 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -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::*; diff --git a/web_assets/announcement/jquery-3.6.0.min.js b/web_assets/announcement/jquery-3.6.0.min.js new file mode 100644 index 0000000..200b54e --- /dev/null +++ b/web_assets/announcement/jquery-3.6.0.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
    ",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 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 FV1iTA2R8r! literal 0 HcmV?d00001 diff --git a/web_assets/announcement/news_common.css b/web_assets/announcement/news_common.css new file mode 100644 index 0000000..c56a275 --- /dev/null +++ b/web_assets/announcement/news_common.css @@ -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; +} + +/* 装飾用 */ +/*
    */ +#detail .title { + font-weight: bold; +} + +/* */ +#detail .bold { + font-weight: bold; +} + +/*
    */ +#detail .date { + color: #f93981; +} + +/* */ +#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; +} diff --git a/web_assets/announcement/news_icon_event.png b/web_assets/announcement/news_icon_event.png new file mode 100644 index 0000000000000000000000000000000000000000..104d8a2baba4180e2e4c6b68a5458bc8eaf8c37b GIT binary patch literal 3704 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 literal 0 HcmV?d00001 diff --git a/web_assets/announcement/news_icon_maintenance.png b/web_assets/announcement/news_icon_maintenance.png new file mode 100644 index 0000000000000000000000000000000000000000..9cfcc8ea510eb90bef4315ee7c7e83548fafa9fb GIT binary patch literal 5596 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 Y()>s#t<%#d|NL*&P|;B?SG0@xA6ytpp8x;= literal 0 HcmV?d00001 diff --git a/web_assets/announcement/news_icon_new.png b/web_assets/announcement/news_icon_new.png new file mode 100644 index 0000000000000000000000000000000000000000..66dbcac8d9575de79d760bd765877dadf6be3d4a GIT binary patch literal 2788 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 literal 0 HcmV?d00001 diff --git a/web_assets/announcement/news_icon_shop.png b/web_assets/announcement/news_icon_shop.png new file mode 100644 index 0000000000000000000000000000000000000000..0996dc2e36ac6b541ad66898c91a0d9679c569e1 GIT binary patch literal 3606 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 U?qqRjQvd(}07*qoM6N<$g5{5)1poj5 literal 0 HcmV?d00001 diff --git a/web_assets/announcement/sanitize.css b/web_assets/announcement/sanitize.css new file mode 100644 index 0000000..f4c3f7d --- /dev/null +++ b/web_assets/announcement/sanitize.css @@ -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; +}