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 0 */ +#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 0000000..104d8a2 Binary files /dev/null and b/web_assets/announcement/news_icon_event.png differ diff --git a/web_assets/announcement/news_icon_gacha.png b/web_assets/announcement/news_icon_gacha.png new file mode 100644 index 0000000..819f638 Binary files /dev/null and b/web_assets/announcement/news_icon_gacha.png differ diff --git a/web_assets/announcement/news_icon_maintenance.png b/web_assets/announcement/news_icon_maintenance.png new file mode 100644 index 0000000..9cfcc8e Binary files /dev/null and b/web_assets/announcement/news_icon_maintenance.png differ diff --git a/web_assets/announcement/news_icon_new.png b/web_assets/announcement/news_icon_new.png new file mode 100644 index 0000000..66dbcac Binary files /dev/null and b/web_assets/announcement/news_icon_new.png differ diff --git a/web_assets/announcement/news_icon_news.png b/web_assets/announcement/news_icon_news.png new file mode 100644 index 0000000..32472a0 Binary files /dev/null and b/web_assets/announcement/news_icon_news.png differ diff --git a/web_assets/announcement/news_icon_others.png b/web_assets/announcement/news_icon_others.png new file mode 100644 index 0000000..ca83d9a Binary files /dev/null and b/web_assets/announcement/news_icon_others.png differ diff --git a/web_assets/announcement/news_icon_shop.png b/web_assets/announcement/news_icon_shop.png new file mode 100644 index 0000000..0996dc2 Binary files /dev/null and b/web_assets/announcement/news_icon_shop.png differ diff --git a/web_assets/announcement/news_img_arrow.png b/web_assets/announcement/news_img_arrow.png new file mode 100644 index 0000000..8a0ed37 Binary files /dev/null and b/web_assets/announcement/news_img_arrow.png differ 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; +}