Public Access
feat(psn): link PlayStation account and import played games
The NPSSO is used once to get a refresh token; only that token is stored, sealed with AES-GCM (SecretBox, key APP_PSN_TOKEN_KEY; no key = feature off). One-time import of played PS4/PS5 titles through the import pipeline as source PSN. Unlink keeps the games. One PSN call per user at a time. IGDB search now sends UTF-8, so non-ASCII names like "Yōtei" match again.
This commit is contained in:
1 parent
e87f40bba0
commit
7ac887ed5a
72 files changed
+2407
-78
No files matched your search
@@ -136,6 +136,9 @@ To Be Played is split into three repositories:
|
||||
then after 30 days.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
|
||||
token (about 10 days) and one click imports the played PS4/PS5 titles with playtime through the import pipeline. See
|
||||
"PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
|
||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||
@@ -208,6 +211,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
|
||||
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
|
||||
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
|
||||
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt}`, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
|
||||
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt}`), unlink (`204`, deletes the stored token) | JWT |
|
||||
| `POST` | `/api/library/psn-account/imports` (reads the played titles; `202 {id}` like an import, `204` no titles, `404 PSN_NOT_LINKED`, `409 PSN_RELINK` / `IMPORT_RUNNING`, `502 PSN_ERROR`, `503 PSN_OFF` / `IGDB_OFF`) | JWT |
|
||||
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||
@@ -266,9 +272,11 @@ flowchart LR
|
||||
Steam["steam_adapter"]
|
||||
Igdb["igdb_adapter"]
|
||||
Speedrun["speedrun_adapter"]
|
||||
Psn["psn_adapter"]
|
||||
LibService --> Steam
|
||||
LibService --> Igdb
|
||||
LibService --> Speedrun
|
||||
LibService --> Psn
|
||||
end
|
||||
Browser -->|"tbp_access cookie"| Security
|
||||
Rewards --> Db
|
||||
@@ -278,6 +286,7 @@ flowchart LR
|
||||
Igdb --> IgdbApi["IGDB API"]
|
||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||
Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"]
|
||||
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")]
|
||||
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
|
||||
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
|
||||
@@ -288,7 +297,7 @@ flowchart LR
|
||||
|
||||
| Module | Content |
|
||||
|---|---|
|
||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||
| `library` | Sync, backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
|
||||
| `stats` | Public numbers for the landing page |
|
||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
|
||||
@@ -297,6 +306,7 @@ flowchart LR
|
||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles |
|
||||
|
||||
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
|
||||
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
|
||||
@@ -314,8 +324,8 @@ a column of `user_game` drops this view first, then `listed_user_game`.
|
||||
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
|
||||
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
||||
|
||||
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter` and
|
||||
`speedrun_adapter`.
|
||||
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
|
||||
`speedrun_adapter` and `psn_adapter`.
|
||||
- No cycles between `modules`, `shared` and `configuration`.
|
||||
- `api` never uses `repo`, and `repo` never uses `api`.
|
||||
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
|
||||
@@ -504,6 +514,7 @@ user ids, which removes the rows of users Auth does not know (refused for more t
|
||||
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
|
||||
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
|
||||
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
|
||||
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
|
||||
|
||||
### Blocked users and Steam ID changes
|
||||
|
||||
@@ -528,7 +539,7 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games
|
||||
|
||||
`user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with
|
||||
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
|
||||
`STEAM`, and status, rating and notes stay. Only `MANUAL` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||
`STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
|
||||
a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two
|
||||
@@ -570,10 +581,34 @@ stateDiagram-v2
|
||||
FAILED --> UNDONE: DELETE /games (the games added before it failed)
|
||||
```
|
||||
|
||||
The undo deletes the games of the import that are still `MANUAL` (a game the Steam sync took over stays). A removed
|
||||
The undo deletes the games of the import that are still `MANUAL` or `PSN` (a game the Steam sync took over stays). A removed
|
||||
row the import brings back gets no `import_id`: it holds rating, notes and history from before, so the undo keeps it;
|
||||
`undo-check` counts those with a rating, notes, priority or a status change after the save.
|
||||
|
||||
### PlayStation link
|
||||
|
||||
`modules/psn_adapter` calls PlayStation's unofficial API (the public client of the PlayStation app, as the `psn-api`
|
||||
project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` holds the use cases:
|
||||
|
||||
- **Link** (`POST /api/library/psn-account`): the NPSSO (the user's `npsso` cookie from playstation.com) goes to
|
||||
`/authorize` (the redirect is not followed: `Redirect.NEVER`), the `code` in its `Location` header to `/token`.
|
||||
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
|
||||
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
|
||||
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
|
||||
wrong length stops Core at start. A changed key makes stored links unreadable: the import answers `PSN_RELINK`.
|
||||
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
|
||||
refresh token keeps the stored expiry; a new one stores its own. An expired link answers `409 PSN_RELINK`; the user
|
||||
pastes a new NPSSO. The Settings page shows the date.
|
||||
- **Import** (`POST /api/library/psn-account/imports`): one refresh call, then `gamelist/v2` (200 per page, at most
|
||||
1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no PS4/PS5 suffix) and joins the PS4 and PS5 versions of one
|
||||
`concept.id` into one line (name and platform of the most played version, playtime summed). The lines go through
|
||||
`GameImportService.createLines` as a `PSN` import: IGDB match, review, save, undo. Saved rows have `source = PSN` and
|
||||
`platform` PS5 or PS4. A game also owned on Steam stays one row (the Steam row wins); its PSN playtime is not added.
|
||||
The client cannot start a `PSN` import with its own rows.
|
||||
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
|
||||
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
|
||||
move it back. Repeating sync and trophies are not built.
|
||||
|
||||
## Run locally
|
||||
|
||||
### Local development without keys (profile `local`)
|
||||
@@ -607,6 +642,9 @@ What the `local` profile does:
|
||||
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
|
||||
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
|
||||
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
|
||||
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The import then
|
||||
lists 4 games: Ghost of Yōtei, Mega Man Legacy Collection, Ghost of Tsushima (PS4 and PS5 version as one line) and Monster
|
||||
Hunter Wilds, which IGDB's recording does not know: the review shows an unrelated guess, not checked. The local profile has a fixed token key.
|
||||
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
|
||||
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||
|
||||
@@ -637,6 +675,7 @@ Configuration comes from environment variables:
|
||||
| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both |
|
||||
| `STEAM_API_KEY` | Steam Web API key |
|
||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
||||
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
|
||||
## Tests
|
||||
|
||||
Reference in new issue
Block a user