diff --git a/crates/net/src/user_profile.rs b/crates/net/src/user_profile.rs index 7611c1d..6032e65 100644 --- a/crates/net/src/user_profile.rs +++ b/crates/net/src/user_profile.rs @@ -1,6 +1,6 @@ //! The web API group `userProfile` ([`spec/web-api/userProfile.md`](../../../spec/web-api/userProfile.md)): -//! the signed-in player's friends and the profiles of the player and those friends, from the -//! AnyPlay friend list the service already holds. Nothing here asks the cloud. +//! the signed-in player's friends and blocked accounts and the profiles of the player and those +//! friends, from the AnyPlay friend list the service already holds. Nothing here asks the cloud. //! //! The id a game sees for an account is the AnyPlay account number itself, for the player and //! for every friend, so both players' PCs agree on it. @@ -23,8 +23,9 @@ pub const MAX_LIMIT: usize = 2000; /// The most profiles one request is answered for; further ids are left out. pub const MAX_PROFILES: usize = 100; -/// Override keys (see the spec): the two calls of the group. +/// Override keys (see the spec): the calls of the group. const FRIENDS: &str = "friends"; +const BLOCKS: &str = "blocks"; const PROFILES: &str = "profiles"; /// How an id is written. @@ -58,6 +59,8 @@ pub struct Shape { pub items: Items, /// The array of friends in the `friends` answer. pub friends_key: String, + /// The array of blocked accounts in the `blocks` answer. + pub blocks_key: String, /// The array of profiles in the `profiles` answer. pub profiles_key: String, /// The id of a profile or of a friend written as an object. @@ -75,6 +78,7 @@ impl Default for Shape { ids: IdForm::default(), items: Items::default(), friends_key: "friends".into(), + blocks_key: "blocks".into(), profiles_key: "profiles".into(), id_key: "accountId".into(), name_key: "onlineId".into(), @@ -122,9 +126,9 @@ pub fn answer(web: &WebRequest, ctx: &Context<'_>) -> Option { let (path, query) = web.path.split_once('?').unwrap_or((web.path.as_str(), "")); let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); match parts[..] { - ["v1", "users", user, "friends"] => { + ["v1", "users", user, call @ ("friends" | "blocks")] => { if user != "me" && user.parse::().ok() != Some(me) { - // Another player's friends are not ours to tell. + // Another player's friends and blocks are not ours to tell. tracing::debug!( target: "anyplay_net::webapi", group = GROUP, @@ -132,7 +136,11 @@ pub fn answer(web: &WebRequest, ctx: &Context<'_>) -> Option { ); return None; } - Some(friends(me, ctx, query)) + Some(if call == "friends" { + friends(me, ctx, query) + } else { + blocks(me, ctx, query) + }) } ["v1", "users", "profiles"] => Some(profiles(me, ctx, query)), _ => None, @@ -159,12 +167,37 @@ fn visible_friends(me: u64, lists: &FriendLists) -> Vec { friends } +/// The accounts the player blocked, in the same order as the friends. The player themself never +/// appears. +fn visible_blocks(me: u64, lists: &FriendLists) -> Vec { + let mut seen = HashSet::new(); + let mut blocks: Vec = lists + .blocked + .iter() + .filter(|a| a.id.0 != me && seen.insert(a.id.0)) + .map(|a| Seen { + id: a.id.0, + handle: a.handle.clone(), + }) + .collect(); + blocks.sort_by_cached_key(|b| (b.handle.to_lowercase(), b.id)); + blocks +} + fn friends(me: u64, ctx: &Context<'_>, query: &str) -> WebReply { - let all = visible_friends(me, ctx.friends); + listing(me, ctx, query, FRIENDS, visible_friends(me, ctx.friends)) +} + +fn blocks(me: u64, ctx: &Context<'_>, query: &str) -> WebReply { + listing(me, ctx, query, BLOCKS, visible_blocks(me, ctx.friends)) +} + +/// A paged list of accounts, the answer of `friends` and of `blocks`. +fn listing(me: u64, ctx: &Context<'_>, query: &str, call: &str, all: Vec) -> WebReply { let offset = number(query, "offset").unwrap_or(0); let limit = number(query, "limit").map_or(MAX_LIMIT, |l| l.min(MAX_LIMIT)); let page: Vec<&Seen> = all.iter().skip(offset).take(limit).collect(); - let (shape, replacement) = shape_of(ctx, FRIENDS); + let (shape, replacement) = shape_of(ctx, call); let items: Vec = page .iter() .map(|f| match shape.items { @@ -173,7 +206,12 @@ fn friends(me: u64, ctx: &Context<'_>, query: &str) -> WebReply { }) .collect(); let mut body = Map::new(); - put(&mut body, &shape.friends_key, Value::Array(items)); + let key = if call == BLOCKS { + &shape.blocks_key + } else { + &shape.friends_key + }; + put(&mut body, key, Value::Array(items)); put(&mut body, &shape.offset_key, offset.into()); put(&mut body, &shape.limit_key, limit.into()); put(&mut body, &shape.total_key, all.len().into()); @@ -200,7 +238,7 @@ fn friends(me: u64, ctx: &Context<'_>, query: &str) -> WebReply { ]; reply( me, - FRIENDS, + call, page.len(), replacement, &Value::Object(body), @@ -462,6 +500,86 @@ mod tests { assert_eq!(body(&asked)["profiles"], json!([])); } + const BLOCKS_PATH: &str = "/v1/users/me/blocks?limit=2000"; + + #[test] + fn blocks_are_listed_for_me_and_for_the_own_account_id() { + let l = lists(&[(43, "bob")], &[(47, "Zoe"), (45, "amy"), (46, "Amy")]); + let want = json!({"blocks": ["45", "46", "47"], "offset": 0, "limit": 2000, "total": 3}); + for path in [BLOCKS_PATH, "/v1/users/42/blocks?offset=0&limit=2000"] { + let reply = answer(&request(path), &ctx(&l, None)).unwrap(); + assert_eq!(reply.status, 200, "{path}"); + assert_eq!(body(&reply), want, "{path}"); + } + } + + #[test] + fn blocks_of_another_account_or_a_signed_out_player_are_not_offered() { + let l = lists(&[], &[(47, "zoe")]); + let c = ctx(&l, None); + assert!(answer(&request("/v1/users/43/blocks"), &c).is_none()); + assert!(answer(&request("/v1/users/local_268435456/blocks?"), &c).is_none()); + assert!(answer(&request("/v1/users/42/blocks/extra"), &c).is_none()); + let mut post = request(BLOCKS_PATH); + post.method = "POST".into(); + assert!(answer(&post, &c).is_none()); + let mut out = ctx(&l, None); + out.account = None; + assert!(answer(&request(BLOCKS_PATH), &out).is_none()); + } + + #[test] + fn blocks_are_paged_and_without_blocks_the_list_is_empty() { + let l = lists( + &[], + &[(1, "a"), (2, "b"), (3, "c"), (4, "d"), (3, "c"), (42, "me")], + ); + let page = |q: &str| { + let reply = answer( + &request(&format!("/v1/users/me/blocks?{q}")), + &ctx(&l, None), + ); + body(&reply.unwrap()) + }; + assert_eq!(page("offset=1&limit=2")["blocks"], json!(["2", "3"])); + assert_eq!(page("offset=1&limit=2")["total"], 4); + assert_eq!(page("offset=9")["blocks"], json!([])); + assert_eq!(page("limit=999999")["limit"], MAX_LIMIT); + let none = lists(&[(43, "bob")], &[]); + let reply = answer(&request(BLOCKS_PATH), &ctx(&none, None)).unwrap(); + assert_eq!(reply.status, 200); + assert_eq!( + body(&reply), + json!({"blocks": [], "offset": 0, "limit": 2000, "total": 0}) + ); + } + + #[test] + fn a_shape_or_reply_override_applies_to_blocks_only() { + let (_dir, file) = overrides( + r#"{"userProfile": { + "blocks": {"shape": {"items": "objects", "ids": "number", + "blocks_key": "list", "id_key": "id", "name_key": "name", "limit_key": ""}}}}"#, + ); + let l = lists(&[(43, "bob")], &[(47, "zoe")]); + let c = ctx(&l, Some(&file)); + let b = answer(&request(BLOCKS_PATH), &c).unwrap(); + assert_eq!( + body(&b), + json!({"list": [{"id": 47, "name": "zoe"}], "offset": 0, "total": 1}) + ); + let f = answer(&request(FRIENDS_PATH), &c).unwrap(); + assert_eq!(body(&f)["friends"], json!(["43"])); + let (_dir2, whole) = overrides( + r#"{"userProfile": {"blocks": {"status": 200, "body_text": "{\"n\":{total},\"b\":{ids}}"}}}"#, + ); + let b = answer(&request(BLOCKS_PATH), &ctx(&l, Some(&whole))).unwrap(); + assert_eq!( + String::from_utf8(b.body).unwrap(), + "{\"n\":1,\"b\":[\"47\"]}" + ); + } + #[test] fn profiles_show_the_player_and_friends_with_the_handle_as_online_name() { let l = lists(&[(43, "bob")], &[]); diff --git a/spec/web-api/userProfile.md b/spec/web-api/userProfile.md index 7337e42..70c293a 100644 --- a/spec/web-api/userProfile.md +++ b/spec/web-api/userProfile.md @@ -22,6 +22,15 @@ Status: **offered**, with body shapes that are guesses until a game we own has a | How often | signed out: 4 times, the first 105 s after the start, then every 5 to 10 s; signed in: once, when the player chooses to host, after `communicationRestrictionStatus` | | Calls | `sceNpWebApi2CreateUserContext` once, then for each request `sceNpWebApi2CreateRequest`, `sceNpWebApi2AddHttpRequestHeader`, `sceNpWebApi2SendRequest` and `sceNpWebApi2DeleteRequest` | +### `GET /v1/users/{user}/blocks` + +| Part | Seen | +|---|---| +| `{user}` | `me` | +| Query | `limit=2000` | +| Body | none | +| How often | once at the start of a signed-in run, next to `communicationRestrictionStatus`; while the call was not offered, `sceNpWebApi2SendRequest` returned `0x80553406` with status 0 | + ### `GET /v1/users/profiles?accountIds={id}` | Part | Seen | @@ -56,6 +65,14 @@ Offered when `{user}` is the signed-in account's ID or `me`. Another ID, and the { "friends": ["1002", "1005"], "offset": 0, "limit": 2000, "total": 2 } ``` +### `GET /v1/users/{user}/blocks` + +Offered under the same rules as `friends`: `{user}` is the signed-in account's ID or `me`, anything else stays "not offered". The answer lists the **AnyPlay accounts the player blocked** and no one else, taken from the `blocked` list of the same cloud friend list the friends come from, so nothing more is asked of the cloud. It never says who blocked the player, and it holds nothing about accounts of any platform. The player themself never appears. Order and paging are those of `friends` (`offset`, `limit`, default and maximum 2000, `total` before paging). A player with no blocks gets status 200 and an empty list. A blocked account is also left out of `friends` and `profiles`. + +```json +{ "blocks": ["1007"], "offset": 0, "limit": 2000, "total": 1 } +``` + ### `GET /v1/users/profiles?accountIds={id}[,{id}...]` `accountIds` may be repeated and may hold a comma-separated list (`%2C` works too); `me` stands for the player. The answer holds a profile for each requested account that is **the player or one of their accepted friends**, in the order asked, each once, at most 100 per request. Any other id is **left out**: an account that is not a friend, a blocked one, one that does not exist and a value that is not a number all look the same, so the answer never says whether an account exists. A request with nothing to show gets status 200 and an empty list. @@ -74,7 +91,7 @@ Each answer is logged at debug level (`ANYPLAY_NET_LOG=anyplay_net::webapi=debug ## Trying other shapes (testers) -Use the override file of the other groups: `ANYPLAY_NET_WEBAPI_OVERRIDES=` (off when unset; for testing only). It is read on every request, so edit it while the game runs; a file that cannot be read or parsed is logged as a warning and the defaults are used. The group is `userProfile`, the two calls are `friends` and `profiles`. Each call takes a `shape` for the default answer, a whole reply, or both (the whole reply wins): +Use the override file of the other groups: `ANYPLAY_NET_WEBAPI_OVERRIDES=` (off when unset; for testing only). It is read on every request, so edit it while the game runs; a file that cannot be read or parsed is logged as a warning and the defaults are used. The group is `userProfile`, the three calls are `friends`, `blocks` and `profiles`. Each call takes a `shape` for the default answer, a whole reply, or both (the whole reply wins): ```json { @@ -90,12 +107,12 @@ Use the override file of the other groups: `ANYPLAY_NET_WEBAPI_OVERRIDES=` | Key of `shape` | Default | Meaning | |---|---|---| | `ids` | `"string"` | `"string"` writes `"1002"`, `"number"` writes `1002` | -| `items` | `"ids"` | what a friend is in the `friends` list: `"ids"` or `"objects"` (id and name, like a profile) | -| `friends_key`, `profiles_key` | `friends`, `profiles` | the array's name | +| `items` | `"ids"` | what an entry is in the `friends` and `blocks` lists: `"ids"` or `"objects"` (id and name, like a profile) | +| `friends_key`, `blocks_key`, `profiles_key` | `friends`, `blocks`, `profiles` | the array's name | | `id_key`, `name_key` | `accountId`, `onlineId` | the fields of a profile, and of a friend written as an object | -| `offset_key`, `limit_key`, `total_key` | `offset`, `limit`, `total` | the paging fields of the `friends` answer | +| `offset_key`, `limit_key`, `total_key` | `offset`, `limit`, `total` | the paging fields of the `friends` and `blocks` answers | -A key set to `""` is left out. A whole reply has `status` (default 200), `headers` (default a JSON content type), `body` (any JSON value) and `body_text` (sent byte for byte, an empty string for an empty body; it wins over `body`). In `body_text` these are replaced by live values: `{account}`, `{handle}`, `{total}`, `{count}`, `{ids}` (the JSON array of the ids shown), `{profiles}` (the JSON array of the profiles shown), and for `friends` also `{offset}` and `{limit}`. They follow the `shape`. Write down in this file what the game did with each shape you tried. +A key set to `""` is left out. A whole reply has `status` (default 200), `headers` (default a JSON content type), `body` (any JSON value) and `body_text` (sent byte for byte, an empty string for an empty body; it wins over `body`). In `body_text` these are replaced by live values: `{account}`, `{handle}`, `{total}`, `{count}`, `{ids}` (the JSON array of the ids shown), `{profiles}` (the JSON array of the profiles shown), and for `friends` and `blocks` also `{offset}` and `{limit}`. They follow the `shape`. Write down in this file what the game did with each shape you tried. ## Unknown