Public Access
Compare commits
5
Commits
3512159b41
...
216c1af62f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216c1af62f | ||
|
|
e347b7aac8 | ||
|
|
23bf5c41b4 | ||
|
|
34d64e7215 | ||
|
|
504c935b27 |
No files matched your search
@@ -36,5 +36,9 @@ EXPOSE 8090
|
|||||||
# Copy the compiled jar file from the builder stage
|
# Copy the compiled jar file from the builder stage
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
# No root in the container
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
USER app
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -76,13 +76,12 @@ To Be Played is split into three repositories:
|
|||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||||
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Each load replaces
|
||||||
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
|
the key set: a key that left the JWKS stops working. `JWT_PUBLIC_KEY` is used only until the first JWKS load works.
|
||||||
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
|
The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
|
||||||
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
|
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
|
||||||
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
||||||
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`,
|
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. The claims are pinned in
|
||||||
user id in `userId`) and is still accepted; item 29 removes that path. The claims are pinned in
|
|
||||||
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
|
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
|
||||||
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
|
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
|
||||||
frontend origin.
|
frontend origin.
|
||||||
@@ -98,24 +97,31 @@ only guard.
|
|||||||
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
|
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
|
||||||
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
|
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
|
||||||
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
|
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
|
||||||
scheduled jobs and background work run without a user and are not checked. A denied call answers `401` without a
|
scheduled jobs and background work run without a user and are not checked. That stays on purpose: the sync
|
||||||
|
worker calls `SteamApiService` and the import worker `ManualGameService`, both `@RequiresRole(USER)`, without a
|
||||||
|
request. Auth denies instead, because it has no such caller. A denied call answers `401` without a
|
||||||
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
|
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
|
||||||
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
|
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
|
||||||
method calling another service's method that needs more.
|
method calling another service's method that needs more.
|
||||||
|
|
||||||
|
**Mirrored in Auth.** These files exist in both services with small differences; a change in one needs the same
|
||||||
|
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
|
||||||
|
`Public` (only Core has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
|
||||||
|
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
|
||||||
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/backlog` (replaces all four; removed in item 29) | JWT |
|
|
||||||
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||||
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||||
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
|
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
|
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
|
||||||
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
|
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
|
||||||
| `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 |
|
| `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}/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/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
|
||||||
@@ -129,6 +135,7 @@ method calling another service's method that needs more.
|
|||||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
@@ -173,9 +180,9 @@ flowchart LR
|
|||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, deleted_user"| Db
|
Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Content |
|
| Module | Content |
|
||||||
@@ -183,15 +190,16 @@ flowchart LR
|
|||||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||||
| `stats` | Public numbers for the landing page |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `account` | Deleted accounts, driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows |
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `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 |
|
| `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 |
|
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||||
|
|
||||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
|
||||||
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into
|
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||||
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls.
|
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||||
|
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
|
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
|
||||||
|
|
||||||
@@ -222,7 +230,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
dates finished games by the unlock of their ending achievement
|
dates finished games by the unlock of their ending achievement
|
||||||
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
|
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
|
||||||
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
|
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
|
||||||
a user in `deleted_user`.
|
a user in `deleted_user` or `blocked_user`.
|
||||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
||||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
||||||
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
||||||
@@ -310,6 +318,16 @@ flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned
|
|||||||
user ids, which removes the rows of users Auth does not know (refused for more than
|
user ids, which removes the rows of users Auth does not know (refused for more than
|
||||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||||
|
|
||||||
|
### Blocked users and Steam ID changes
|
||||||
|
|
||||||
|
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
||||||
|
took by a verified link, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
||||||
|
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
|
||||||
|
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
|
||||||
|
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
|
||||||
|
more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
|
||||||
|
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
|
||||||
|
|
||||||
### Manual games next to Steam games
|
### Manual games next to Steam games
|
||||||
|
|
||||||
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
|
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class CacheConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary",
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary",
|
||||||
"igdbSearch");
|
"igdbSearch");
|
||||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(10, TimeUnit.MINUTES)
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import io.jsonwebtoken.Jwts;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -18,9 +17,8 @@ import java.util.stream.Collectors;
|
|||||||
/**
|
/**
|
||||||
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
|
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
|
||||||
* <p>
|
* <p>
|
||||||
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} comes
|
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} is
|
||||||
* from an Auth from before item 26: its kind is in {@code typ} and the user id in {@code userId}. Item 29 removes
|
* refused.
|
||||||
* that path.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class JwtService implements ProfileTicketVerifier {
|
public class JwtService implements ProfileTicketVerifier {
|
||||||
@@ -29,10 +27,6 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
static final String LOGIN_AUDIENCE = "tbp-login";
|
static final String LOGIN_AUDIENCE = "tbp-login";
|
||||||
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
||||||
static final String SERVICE_AUDIENCE = "tbp-internal";
|
static final String SERVICE_AUDIENCE = "tbp-internal";
|
||||||
// The kind of a token before item 26; item 29 removes it.
|
|
||||||
private static final String TYPE_CLAIM = "typ";
|
|
||||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
|
||||||
private static final String SERVICE_TOKEN_TYPE = "service";
|
|
||||||
private static final String ROLE_CLAIM = "role";
|
private static final String ROLE_CLAIM = "role";
|
||||||
|
|
||||||
private final JwtParser parser;
|
private final JwtParser parser;
|
||||||
@@ -45,18 +39,9 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
return parser.parseSignedClaims(token).getPayload();
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasAudience(Claims claims) {
|
private static boolean isKind(Claims claims, String audience) {
|
||||||
return claims.getAudience() != null && !claims.getAudience().isEmpty();
|
return claims.getAudience() != null && claims.getAudience().contains(audience)
|
||||||
}
|
&& ISSUER.equals(claims.getIssuer());
|
||||||
|
|
||||||
/**
|
|
||||||
* @param legacyType the {@code typ} of this kind before item 26, null for a login
|
|
||||||
*/
|
|
||||||
private static boolean isKind(Claims claims, String audience, String legacyType) {
|
|
||||||
if (hasAudience(claims)) {
|
|
||||||
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
|
|
||||||
}
|
|
||||||
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +51,10 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public Optional<UserPrincipal> parseLoginToken(String token) {
|
public Optional<UserPrincipal> parseLoginToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(token);
|
Claims claims = claims(token);
|
||||||
if (!isKind(claims, LOGIN_AUDIENCE, null)) {
|
if (!isKind(claims, LOGIN_AUDIENCE)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
String userId = hasAudience(claims) ? claims.getSubject()
|
String userId = claims.getSubject();
|
||||||
: claims.getSubject() == null ? null : claims.get("userId", String.class);
|
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -87,7 +71,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public boolean isServiceToken(String token) {
|
public boolean isServiceToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(token);
|
Claims claims = claims(token);
|
||||||
return isKind(claims, SERVICE_AUDIENCE, SERVICE_TOKEN_TYPE) && claims.getExpiration() != null;
|
return isKind(claims, SERVICE_AUDIENCE) && claims.getExpiration() != null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -101,7 +85,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(ticket);
|
Claims claims = claims(ticket);
|
||||||
if (!isKind(claims, PROFILE_TICKET_AUDIENCE, PROFILE_TICKET_TYPE)) {
|
if (!isKind(claims, PROFILE_TICKET_AUDIENCE)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package core_service.modules.account.api;
|
package core_service.modules.account.api;
|
||||||
|
|
||||||
|
import core_service.modules.account.api.dto.AccountStatesRequest;
|
||||||
import core_service.modules.account.api.dto.CleanupResultDto;
|
import core_service.modules.account.api.dto.CleanupResultDto;
|
||||||
import core_service.modules.account.api.dto.KnownUsersRequest;
|
import core_service.modules.account.api.dto.KnownUsersRequest;
|
||||||
import core_service.modules.account.api.dto.PurgeRequest;
|
import core_service.modules.account.api.dto.PurgeRequest;
|
||||||
import core_service.modules.account.api.dto.UserIdsRequest;
|
import core_service.modules.account.api.dto.UserIdsRequest;
|
||||||
|
import core_service.modules.account.domain.AccountState;
|
||||||
import core_service.modules.account.domain.CleanupResult;
|
import core_service.modules.account.domain.CleanupResult;
|
||||||
import core_service.modules.account.service.AccountCleanupService;
|
import core_service.modules.account.service.AccountCleanupService;
|
||||||
|
import core_service.modules.account.service.AccountStateService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -15,12 +18,15 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/accounts")
|
@RequestMapping("/internal/accounts")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InternalAccountController {
|
public class InternalAccountController {
|
||||||
|
|
||||||
private final AccountCleanupService accountCleanupService;
|
private final AccountCleanupService accountCleanupService;
|
||||||
|
private final AccountStateService accountStateService;
|
||||||
|
|
||||||
@PostMapping("/deleted")
|
@PostMapping("/deleted")
|
||||||
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
|
public ResponseEntity<Void> markDeleted(@Valid @RequestBody UserIdsRequest request) {
|
||||||
@@ -44,6 +50,24 @@ public class InternalAccountController {
|
|||||||
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
|
return ResponseEntity.ok(toDto(accountCleanupService.prune(request.knownUserIds(), request.dryRun())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/states")
|
||||||
|
public ResponseEntity<Void> applyStates(@Valid @RequestBody AccountStatesRequest request) {
|
||||||
|
accountStateService.apply(toStates(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/states")
|
||||||
|
public ResponseEntity<Void> replaceStates(@Valid @RequestBody AccountStatesRequest request) {
|
||||||
|
accountStateService.replaceAll(toStates(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AccountState> toStates(AccountStatesRequest request) {
|
||||||
|
return request.accounts().stream()
|
||||||
|
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
private static CleanupResultDto toDto(CleanupResult result) {
|
private static CleanupResultDto toDto(CleanupResult result) {
|
||||||
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
|
return new CleanupResultDto(result.dryRun(), result.users(), result.rows(), result.refused());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.account.api.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
|
||||||
|
|
||||||
|
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.account.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Core copies from Auth for one user.
|
||||||
|
*
|
||||||
|
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
||||||
|
*/
|
||||||
|
public record AccountState(long userId, Long steamId, boolean blocked) {
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -21,11 +22,11 @@ public class AccountCleanupRepository {
|
|||||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||||
"user_steam_sync", "user_game", "game_import", "deleted_user");
|
"user_steam_sync", "user_game", "game_import", "blocked_user", "deleted_user");
|
||||||
|
|
||||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||||
"game_import", "deleted_user");
|
"game_import", "blocked_user", "deleted_user");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -90,13 +91,15 @@ public class AccountCleanupRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users that have rows in Core but are not in the list.
|
* Users that have rows in Core but are not in the list. Ids above the highest known one are left out: Auth's
|
||||||
|
* ids never come back, so such a user registered after Auth read the list.
|
||||||
*
|
*
|
||||||
* @param knownUserIds not empty
|
* @param knownUserIds not empty
|
||||||
*/
|
*/
|
||||||
public List<Long> findOrphans(Collection<Long> knownUserIds) {
|
public List<Long> findOrphans(Collection<Long> knownUserIds) {
|
||||||
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
|
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
|
||||||
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id",
|
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) "
|
||||||
ids(knownUserIds), Long.class);
|
+ "and user_id < :highest order by user_id",
|
||||||
|
ids(knownUserIds).addValue("highest", Collections.max(knownUserIds)), Long.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package core_service.modules.account.repo;
|
||||||
|
|
||||||
|
import core_service.modules.account.domain.AccountState;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library
|
||||||
|
* module owns the entities.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccountStateRepository {
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public void apply(AccountState state) {
|
||||||
|
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
|
||||||
|
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
|
||||||
|
if (state.blocked()) {
|
||||||
|
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
|
||||||
|
} else {
|
||||||
|
jdbc.update("delete from blocked_user where user_id = :userId", params);
|
||||||
|
}
|
||||||
|
if (state.blocked() || state.steamId() == null) {
|
||||||
|
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
|
||||||
|
params);
|
||||||
|
} else {
|
||||||
|
// A new ID: no nightly sync with the old one, and the next visit syncs at once. steam_id stays, so that
|
||||||
|
// sync sees the change and drops the old account's games.
|
||||||
|
jdbc.update("""
|
||||||
|
update user_steam_sync set user_seen_at = null, last_synced_at = null
|
||||||
|
where user_id = :userId and steam_id is distinct from :steamId""", params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After this, exactly the blocked users of the list are blocked.
|
||||||
|
*/
|
||||||
|
public void replaceAll(Collection<AccountState> states) {
|
||||||
|
states.forEach(this::apply);
|
||||||
|
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
|
||||||
|
if (blocked.isEmpty()) {
|
||||||
|
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
|
||||||
|
} else {
|
||||||
|
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package core_service.modules.account.service;
|
||||||
|
|
||||||
|
import core_service.modules.account.domain.AccountState;
|
||||||
|
import core_service.modules.account.repo.AccountStateRepository;
|
||||||
|
import core_service.shared.security.Internal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steam ID and block of each user, driven by Auth. Auth sends a change at once and the whole set nightly, so a
|
||||||
|
* missed call heals by the next night.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Internal
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AccountStateService {
|
||||||
|
|
||||||
|
private final AccountStateRepository repository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)
|
||||||
|
public void apply(List<AccountState> states) {
|
||||||
|
states.forEach(repository::apply);
|
||||||
|
log.info("Account state: {} users updated", states.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)
|
||||||
|
public void replaceAll(List<AccountState> states) {
|
||||||
|
repository.replaceAll(states);
|
||||||
|
log.info("Account state: {} users replaced", states.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package core_service.modules.library.api;
|
|||||||
import core_service.shared.api.ErrorResponse;
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
|
||||||
import core_service.modules.library.api.dto.BacklogPatch;
|
import core_service.modules.library.api.dto.BacklogPatch;
|
||||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||||
import core_service.modules.library.api.dto.CorrectionDto;
|
import core_service.modules.library.api.dto.CorrectionDto;
|
||||||
@@ -57,6 +56,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -87,11 +87,8 @@ public class LibraryController {
|
|||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId),
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
() -> syncService.stopNightlySync(userId));
|
||||||
} else {
|
|
||||||
syncService.stopNightlySync(userId);
|
|
||||||
}
|
|
||||||
long count = queryService.countOwnedGames(userId);
|
long count = queryService.countOwnedGames(userId);
|
||||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
|
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
|
||||||
}
|
}
|
||||||
@@ -108,9 +105,7 @@ public class LibraryController {
|
|||||||
@RequestParam(defaultValue = "false") boolean hidden
|
@RequestParam(defaultValue = "false") boolean hidden
|
||||||
) {
|
) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
int safePage = Math.max(0, page);
|
int safePage = Math.max(0, page);
|
||||||
@@ -132,11 +127,12 @@ public class LibraryController {
|
|||||||
|
|
||||||
@PostMapping("/sync")
|
@PostMapping("/sync")
|
||||||
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal.steamId() == null) {
|
Optional<String> steamId = principal.steamIdText();
|
||||||
|
if (steamId.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
|
syncService.forceSync(principal.getIdAsLong(), steamId.get());
|
||||||
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
||||||
} catch (SyncCooldownException e) {
|
} catch (SyncCooldownException e) {
|
||||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
@@ -152,8 +148,7 @@ public class LibraryController {
|
|||||||
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable("appid") Long appid) {
|
@PathVariable("appid") Long appid) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null;
|
return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid)
|
||||||
return gameDetailService.getGameDetail(userId, steamId, appid)
|
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(GAME_NOT_FOUND));
|
.body(GAME_NOT_FOUND));
|
||||||
@@ -223,16 +218,6 @@ public class LibraryController {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
|
return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/games/{appid}/backlog")
|
|
||||||
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
|
|
||||||
@PathVariable("appid") Long appid,
|
|
||||||
@Valid @RequestBody BacklogEntryDto entry) {
|
|
||||||
return backlogService.update(principal.getIdAsLong(), appid, entry)
|
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
||||||
.body(GAME_NOT_FOUND));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PatchMapping("/games/{appid}/backlog")
|
@PatchMapping("/games/{appid}/backlog")
|
||||||
public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable("appid") Long appid,
|
@PathVariable("appid") Long appid,
|
||||||
@@ -310,9 +295,7 @@ public class LibraryController {
|
|||||||
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
||||||
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
|
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ public record GameImport(long id, long userId, Source source, Status status, int
|
|||||||
int saved, int added, boolean running, int attempts, Step step, Instant createdAt,
|
int saved, int added, boolean running, int attempts, Step step, Instant createdAt,
|
||||||
Instant queuedAt, Instant savedAt) {
|
Instant queuedAt, Instant savedAt) {
|
||||||
|
|
||||||
public enum Source { TEXT, CSV, SEARCH }
|
/**
|
||||||
|
* Where the lines came from. GALAXY, GROUVEE, HLTB and PLAYNITE are exports the Frontend reads; BOOKMARKLET is
|
||||||
|
* the list read on a store page. Core treats them all alike.
|
||||||
|
*/
|
||||||
|
public enum Source { TEXT, CSV, SEARCH, GALAXY, GROUVEE, HLTB, PLAYNITE, BOOKMARKLET }
|
||||||
|
|
||||||
public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED }
|
public enum Status { QUEUED, MATCHING, READY, SAVING, SAVED, UNDONE, FAILED }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game names as the matches compare them (import lines, speedrun.com hits).
|
||||||
|
*/
|
||||||
|
public final class GameNames {
|
||||||
|
|
||||||
|
public static final String TRADEMARK_SIGNS = "[™®©]";
|
||||||
|
|
||||||
|
private GameNames() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
|
||||||
|
*/
|
||||||
|
public static String normalize(String name) {
|
||||||
|
// Before NFKD, which turns ™ into "TM".
|
||||||
|
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
|
||||||
|
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
|
||||||
|
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import core_service.modules.library.domain.UserGameAchievement;
|
|||||||
import core_service.modules.library.domain.UserGameAchievementId;
|
import core_service.modules.library.domain.UserGameAchievementId;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
||||||
|
|
||||||
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
|
||||||
|
List<UserGameAchievement> findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(Long appid,
|
||||||
|
String apiName, Collection<Long> userIds);
|
||||||
|
|
||||||
void deleteByUserIdAndAppid(Long userId, Long appid);
|
void deleteByUserIdAndAppid(Long userId, Long appid);
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
|||||||
/**
|
/**
|
||||||
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
|
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
|
||||||
* (row created, or existing row was not RUNNING / RUNNING marker stale).
|
* (row created, or existing row was not RUNNING / RUNNING marker stale).
|
||||||
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip.
|
* Returns 0 if another sync is already in flight, or the user is deleted or blocked (deleted_user,
|
||||||
|
* blocked_user) — caller must skip.
|
||||||
*
|
*
|
||||||
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
|
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
|
||||||
*/
|
*/
|
||||||
@@ -35,6 +36,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
|||||||
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
|
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
|
||||||
SELECT :userId, :steamId, 'RUNNING', :startedAt
|
SELECT :userId, :steamId, 'RUNNING', :startedAt
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
|
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
|
||||||
ON CONFLICT (user_id) DO UPDATE
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
SET steam_id = EXCLUDED.steam_id,
|
SET steam_id = EXCLUDED.steam_id,
|
||||||
last_sync_status = 'RUNNING',
|
last_sync_status = 'RUNNING',
|
||||||
|
|||||||
@@ -24,23 +24,6 @@ public class BacklogService {
|
|||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
|
|
||||||
/**
|
|
||||||
* @return empty when the game is not in the user's active library
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public Optional<BacklogEntryDto> update(Long userId, Long appid, BacklogEntryDto entry) {
|
|
||||||
return userGameRepository.findById(new UserGameId(userId, appid))
|
|
||||||
.filter(game -> game.getRemovedAt() == null)
|
|
||||||
.map(game -> {
|
|
||||||
game.setStatus(entry.status());
|
|
||||||
game.setStatusSetByUser(true);
|
|
||||||
game.setRating(entry.rating());
|
|
||||||
game.setNotes(entry.notes() == null || entry.notes().isBlank() ? null : entry.notes());
|
|
||||||
game.setPriority(entry.priority());
|
|
||||||
return toDto(game);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
|
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
|
||||||
* alone still lets the sync move the game from BACKLOG to PLAYING.
|
* alone still lets the sync move the game from BACKLOG to PLAYING.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -39,15 +40,17 @@ public class FinishedEstimateService {
|
|||||||
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
|
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
|
||||||
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
|
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
|
||||||
if (ending.isEmpty()) continue;
|
if (ending.isEmpty()) continue;
|
||||||
String apiName = ending.get().achievement().getApiName();
|
// One query per game, not per user: most candidates never unlocked the ending and come back every night.
|
||||||
|
// With the user ids, the primary key (user_id, appid, api_name) serves it.
|
||||||
|
List<Long> userIds = app.getValue().stream().map(FinishedWithoutDate::userId).toList();
|
||||||
|
Map<Long, Instant> unlockByUser = userAchievementRepository
|
||||||
|
.findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(app.getKey(),
|
||||||
|
ending.get().achievement().getApiName(), userIds).stream()
|
||||||
|
.collect(Collectors.toMap(UserGameAchievement::getUserId, UserGameAchievement::getUnlockedAt));
|
||||||
for (FinishedWithoutDate game : app.getValue()) {
|
for (FinishedWithoutDate game : app.getValue()) {
|
||||||
Optional<UserGameAchievement> unlock = userAchievementRepository
|
Instant unlockedAt = unlockByUser.get(game.userId());
|
||||||
.findByUserIdAndAppid(game.userId(), game.appid()).stream()
|
if (unlockedAt != null) {
|
||||||
.filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName))
|
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), unlockedAt);
|
||||||
.findFirst();
|
|
||||||
if (unlock.isPresent()) {
|
|
||||||
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(),
|
|
||||||
unlock.get().getUnlockedAt());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameNames;
|
||||||
import core_service.modules.library.domain.GameImport;
|
import core_service.modules.library.domain.GameImport;
|
||||||
import core_service.modules.library.domain.IgdbGame;
|
import core_service.modules.library.domain.IgdbGame;
|
||||||
import core_service.modules.library.repo.GameImportRepository;
|
import core_service.modules.library.repo.GameImportRepository;
|
||||||
@@ -195,7 +196,7 @@ public class GameImportWorker {
|
|||||||
* Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match.
|
* Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match.
|
||||||
*/
|
*/
|
||||||
static boolean sameName(String a, String b) {
|
static boolean sameName(String a, String b) {
|
||||||
return SpeedrunMatch.normalize(a).equals(SpeedrunMatch.normalize(b));
|
return GameNames.normalize(a).equals(GameNames.normalize(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,11 +212,9 @@ public class LibraryQueryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param profileStats the stat group names of the profile ticket
|
* @param profileStats the stat group names of the profile ticket
|
||||||
*/
|
|
||||||
@Public
|
|
||||||
/**
|
|
||||||
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
|
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
|
||||||
*/
|
*/
|
||||||
|
@Public
|
||||||
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
|
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
|
||||||
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
||||||
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameNames;
|
||||||
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
||||||
|
|
||||||
import java.text.Normalizer;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -21,7 +20,6 @@ final class SpeedrunMatch {
|
|||||||
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
||||||
|
|
||||||
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
||||||
private static final String TRADEMARK_SIGNS = "[™®©]";
|
|
||||||
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
||||||
// The column sizes of game_speedrun.
|
// The column sizes of game_speedrun.
|
||||||
private static final int MAX_GAME_ID_LENGTH = 16;
|
private static final int MAX_GAME_ID_LENGTH = 16;
|
||||||
@@ -64,24 +62,14 @@ final class SpeedrunMatch {
|
|||||||
* The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™".
|
* The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™".
|
||||||
*/
|
*/
|
||||||
static String searchText(String name) {
|
static String searchText(String name) {
|
||||||
return name.replaceAll(TRADEMARK_SIGNS, "").trim();
|
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
|
|
||||||
*/
|
|
||||||
static String normalize(String name) {
|
|
||||||
// Before NFKD, which turns ™ into "TM".
|
|
||||||
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
|
|
||||||
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
|
|
||||||
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NameAndYear split(String name) {
|
private static NameAndYear split(String name) {
|
||||||
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
||||||
return matcher.matches()
|
return matcher.matches()
|
||||||
? new NameAndYear(normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
||||||
: new NameAndYear(normalize(name), null);
|
: new NameAndYear(GameNames.normalize(name), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record NameAndYear(String name, Integer year) {}
|
private record NameAndYear(String name, Integer year) {}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import java.util.Map;
|
|||||||
@Repository
|
@Repository
|
||||||
public class StatsRepository {
|
public class StatsRepository {
|
||||||
|
|
||||||
private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)";
|
private static final String NOT_DELETED_OR_BLOCKED =
|
||||||
|
"user_id NOT IN (SELECT user_id FROM deleted_user UNION SELECT user_id FROM blocked_user)";
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -21,31 +22,31 @@ public class StatsRepository {
|
|||||||
|
|
||||||
public long countDistinctGames() {
|
public long countDistinctGames() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
|
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countLibraryEntries() {
|
public long countLibraryEntries() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
|
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countGamesPlayed() {
|
public long countGamesPlayed() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class);
|
"SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long sumPlaytimeMinutes() {
|
public long sumPlaytimeMinutes() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
|
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUsersWithLibrary() {
|
public long countUsersWithLibrary() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
|
"SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ public class StatsRepository {
|
|||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM listed_user_game
|
||||||
WHERE %s GROUP BY user_id
|
WHERE %s GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ public class StatsRepository {
|
|||||||
SELECT COUNT(*) AS c FROM listed_user_game
|
SELECT COUNT(*) AS c FROM listed_user_game
|
||||||
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
WHERE %s AND playtime_minutes = 0 GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""".formatted(NOT_DELETED), Long.class);
|
""".formatted(NOT_DELETED_OR_BLOCKED), Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ public class StatsRepository {
|
|||||||
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""".formatted(NOT_DELETED), limit);
|
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> topPlayedGames(int limit) {
|
public List<Map<String, Object>> topPlayedGames(int limit) {
|
||||||
@@ -90,6 +91,6 @@ public class StatsRepository {
|
|||||||
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""".formatted(NOT_DELETED), limit);
|
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package core_service.modules.steam_adapter.api;
|
|
||||||
|
|
||||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
|
||||||
import core_service.shared.security.UserPrincipal;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/steam")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SteamController {
|
|
||||||
|
|
||||||
private final SteamApiService steamApiService;
|
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
|
||||||
if (principal.steamId() == null) {
|
|
||||||
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
int count = steamApiService.getOwnedGamesCount(String.valueOf(principal.steamId()));
|
|
||||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -11,7 +11,6 @@ public record SteamOwnedGamesResponse(
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record OwnedGamesResponse(
|
public record OwnedGamesResponse(
|
||||||
@JsonProperty("game_count") Integer gameCount,
|
|
||||||
@JsonProperty("games") List<Game> games
|
@JsonProperty("games") List<Game> games
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -51,51 +51,6 @@ public class SteamApiService {
|
|||||||
@Value("${app.steam.store-country:DE}")
|
@Value("${app.steam.store-country:DE}")
|
||||||
private String storeCountry;
|
private String storeCountry;
|
||||||
|
|
||||||
@Cacheable(value = "steamGameCount", key = "#steamId")
|
|
||||||
@RateLimiter(name = "steamApi")
|
|
||||||
@Retry(name = "steamApiOnce", fallbackMethod = "noGamesCount")
|
|
||||||
public int getOwnedGamesCount(String steamId) {
|
|
||||||
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
|
|
||||||
log.error("Steam API Key is not configured correctly!");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
SteamOwnedGamesResponse response = steamRestClient.get()
|
|
||||||
.uri(uriBuilder -> uriBuilder
|
|
||||||
.path("/IPlayerService/GetOwnedGames/v1/")
|
|
||||||
.queryParam("key", apiKey)
|
|
||||||
.queryParam("steamid", steamId)
|
|
||||||
.queryParam("include_appinfo", "true")
|
|
||||||
.queryParam("include_played_free_games", "true")
|
|
||||||
.queryParam("skip_unvetted_apps", 0)
|
|
||||||
.queryParam("format", "json")
|
|
||||||
.build())
|
|
||||||
.retrieve()
|
|
||||||
.body(SteamOwnedGamesResponse.class);
|
|
||||||
|
|
||||||
if (response != null && response.response() != null) {
|
|
||||||
Integer count = response.response().gameCount();
|
|
||||||
if (count == null) {
|
|
||||||
if (response.response().games() != null) {
|
|
||||||
int size = response.response().games().size();
|
|
||||||
log.info("game_count was null, but games list has {} entries for SteamID: {}", size, steamId);
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
log.warn("Steam API returned an empty response object for SteamID: {}. Check if the ID is correct and 'Game details' are Public.", steamId);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
log.info("Successfully fetched {} games for SteamID: {}", count, steamId);
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fallbacks run after the last try; the callers keep their "Steam failed" rule.
|
|
||||||
private int noGamesCount(String steamId, Exception e) {
|
|
||||||
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asked on every page load for the avatar in the header.
|
// Asked on every page load for the avatar in the header.
|
||||||
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
||||||
@RateLimiter(name = "steamApi")
|
@RateLimiter(name = "steamApi")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.shared.security;
|
package core_service.shared.security;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param role the active role of this login ("view as"), set by Auth
|
* @param role the active role of this login ("view as"), set by Auth
|
||||||
@@ -20,6 +21,13 @@ public record UserPrincipal(
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam ID as the Steam API and user_steam_sync take it; empty, never "null", without one.
|
||||||
|
*/
|
||||||
|
public Optional<String> steamIdText() {
|
||||||
|
return Optional.ofNullable(steamId).map(String::valueOf);
|
||||||
|
}
|
||||||
|
|
||||||
public long getIdAsLong() {
|
public long getIdAsLong() {
|
||||||
return Long.parseLong(id);
|
return Long.parseLong(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ security:
|
|||||||
jwks-url: "http://localhost:8080/.well-known/jwks.json"
|
jwks-url: "http://localhost:8080/.well-known/jwks.json"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
cors:
|
frontend-url: "http://localhost:5173"
|
||||||
allowed-origins: "http://localhost:5173"
|
|
||||||
steam:
|
steam:
|
||||||
api-key: local
|
api-key: local
|
||||||
base-url: "http://localhost:8089/steam"
|
base-url: "http://localhost:8089/steam"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ spring:
|
|||||||
deserialization:
|
deserialization:
|
||||||
accept-float-as-int: false
|
accept-float-as-int: false
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: true
|
open-in-view: false
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
@@ -15,12 +15,6 @@ spring:
|
|||||||
threads:
|
threads:
|
||||||
virtual:
|
virtual:
|
||||||
enabled: true
|
enabled: true
|
||||||
mvc:
|
|
||||||
apiversion:
|
|
||||||
use:
|
|
||||||
query-parameter: version
|
|
||||||
default: "1.0"
|
|
||||||
supported: 1.0, 2,0
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8090
|
port: 8090
|
||||||
@@ -42,8 +36,11 @@ app:
|
|||||||
# Filled from the CI run by the Dockerfile.
|
# Filled from the CI run by the Dockerfile.
|
||||||
commit: ${APP_BUILD_COMMIT:dev}
|
commit: ${APP_BUILD_COMMIT:dev}
|
||||||
time: ${APP_BUILD_TIME:}
|
time: ${APP_BUILD_TIME:}
|
||||||
|
# The frontend; FRONTEND_HOST in the Deployment compose file. The prod host as default: an image that starts before
|
||||||
|
# the compose file sets the env var keeps working.
|
||||||
|
frontend-url: ${APP_FRONTEND_URL:https://tbp.andreas-dahm.eu}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
allowed-origins: ${app.frontend-url}
|
||||||
steam:
|
steam:
|
||||||
api-key: ${STEAM_API_KEY}
|
api-key: ${STEAM_API_KEY}
|
||||||
base-url: https://api.steampowered.com
|
base-url: https://api.steampowered.com
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Users Auth has blocked (item 28 X4). Like deleted_user, but a block can be lifted: no nightly sync, not in the
|
||||||
|
-- stats, no new sync run. The rows stay; unblock deletes the marker.
|
||||||
|
CREATE TABLE blocked_user (
|
||||||
|
user_id BIGINT PRIMARY KEY,
|
||||||
|
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ import core_service.shared.security.Role;
|
|||||||
import core_service.shared.security.UserPrincipal;
|
import core_service.shared.security.UserPrincipal;
|
||||||
import core_service.shared.test.AbstractUnitTest;
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
import core_service.shared.test.ContractTokens;
|
import core_service.shared.test.ContractTokens;
|
||||||
|
import io.jsonwebtoken.JwtBuilder;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -48,10 +49,10 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
|
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signToken(String userId, String email, Long steamId) {
|
private String signToken(String userId, Long steamId) {
|
||||||
var builder = Jwts.builder()
|
JwtBuilder builder = Jwts.builder()
|
||||||
.subject(email)
|
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
|
||||||
.claim("userId", userId)
|
.subject(userId)
|
||||||
.issuedAt(new Date())
|
.issuedAt(new Date())
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000));
|
.expiration(new Date(System.currentTimeMillis() + 60_000));
|
||||||
if (steamId != null) {
|
if (steamId != null) {
|
||||||
@@ -62,7 +63,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ReturnsTheUser() {
|
void parseLoginToken_ReturnsTheUser() {
|
||||||
String token = signToken("0815", "test@example.com", 123456789L);
|
String token = signToken("0815", 123456789L);
|
||||||
|
|
||||||
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
|
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
|
||||||
jwtService.parseLoginToken(token));
|
jwtService.parseLoginToken(token));
|
||||||
@@ -77,8 +78,8 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
void parseLoginToken_RejectsAForeignKey() throws Exception {
|
void parseLoginToken_RejectsAForeignKey() throws Exception {
|
||||||
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
String token = Jwts.builder()
|
String token = Jwts.builder()
|
||||||
.subject("test@example.com")
|
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
|
||||||
.claim("userId", "0815")
|
.subject("0815")
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
|
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
|
||||||
.compact();
|
.compact();
|
||||||
@@ -89,7 +90,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
@Test
|
@Test
|
||||||
void parseLoginToken_RejectsATokenWithoutUserId() {
|
void parseLoginToken_RejectsATokenWithoutUserId() {
|
||||||
String token = Jwts.builder()
|
String token = Jwts.builder()
|
||||||
.subject("test@example.com")
|
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
.signWith(signingKey, Jwts.SIG.EdDSA)
|
.signWith(signingKey, Jwts.SIG.EdDSA)
|
||||||
.compact();
|
.compact();
|
||||||
@@ -97,10 +98,10 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signTicket(String type, String profileUserId, long expiresInMs) {
|
private String signTicket(String audience, String profileUserId, long expiresInMs) {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
|
.issuer(JwtService.ISSUER).audience().add(audience).and()
|
||||||
.subject("public-profile")
|
.subject("public-profile")
|
||||||
.claim("typ", type)
|
|
||||||
.claim("profileUserId", profileUserId)
|
.claim("profileUserId", profileUserId)
|
||||||
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
|
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
|
||||||
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
@@ -109,12 +110,12 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
@Test
|
@Test
|
||||||
void verifyProfileTicket_ReturnsTheUser() {
|
void verifyProfileTicket_ReturnsTheUser() {
|
||||||
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
|
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
|
||||||
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
|
jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyProfileTicket_ReadsTheSharedStatGroups() {
|
void verifyProfileTicket_ReadsTheSharedStatGroups() {
|
||||||
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
|
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
|
||||||
.claim("profileStats", List.of("PILE", "TREND"))
|
.claim("profileStats", List.of("PILE", "TREND"))
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyProfileTicket_ReadsTheShownSections() {
|
void verifyProfileTicket_ReadsTheShownSections() {
|
||||||
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
|
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
|
||||||
.claim("sections", List.of("PLAYING", "STATS"))
|
.claim("sections", List.of("PLAYING", "STATS"))
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
@@ -132,25 +133,26 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
|
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null)));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", null)));
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000)));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", -1_000)));
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000)));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "not-a-number", 60_000)));
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
|
||||||
|
|
||||||
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
||||||
String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42")
|
String foreign = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
|
||||||
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ProfileTicketIsNoLogin() {
|
void parseLoginToken_ProfileTicketIsNoLogin() {
|
||||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) {
|
private String signServiceToken(PrivateKey key, Long expiresInMs) {
|
||||||
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
|
JwtBuilder builder = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.SERVICE_AUDIENCE).and()
|
||||||
|
.subject("tbp-auth").issuedAt(new Date());
|
||||||
if (expiresInMs != null) {
|
if (expiresInMs != null) {
|
||||||
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
|
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
|
||||||
}
|
}
|
||||||
@@ -159,30 +161,30 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isServiceToken_AcceptsAServiceTokenOfAuth() {
|
void isServiceToken_AcceptsAServiceTokenOfAuth() {
|
||||||
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
|
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, 60_000L)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isServiceToken_RejectsEverythingElse() throws Exception {
|
void isServiceToken_RejectsEverythingElse() throws Exception {
|
||||||
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
||||||
|
|
||||||
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
|
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, null)), "no exp");
|
||||||
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
|
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, -1_000L)), "expired");
|
||||||
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
|
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, 60_000L)), "foreign key");
|
||||||
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
|
assertFalse(jwtService.isServiceToken(signToken("42", null)), "login token");
|
||||||
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
|
assertFalse(jwtService.isServiceToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)), "profile ticket");
|
||||||
assertFalse(jwtService.isServiceToken("garbage"));
|
assertFalse(jwtService.isServiceToken("garbage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ServiceTokenIsNoLogin() {
|
void parseLoginToken_ServiceTokenIsNoLogin() {
|
||||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, 60_000L)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signTokenWithRole(String role) {
|
private String signTokenWithRole(String role) {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject("test@example.com")
|
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
|
||||||
.claim("userId", "0815")
|
.subject("0815")
|
||||||
.claim("role", role)
|
.claim("role", role)
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
.signWith(signingKey, Jwts.SIG.EdDSA)
|
.signWith(signingKey, Jwts.SIG.EdDSA)
|
||||||
@@ -196,13 +198,14 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
|
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
|
||||||
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role());
|
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", null)).orElseThrow().role());
|
||||||
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
|
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
|
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
|
||||||
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR"))
|
String token = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
|
||||||
|
.subject("0815").claim("role", List.of("AUDITOR"))
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
|
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
|
||||||
@@ -212,14 +215,6 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
return ContractTokens.sign(claims, signingKey, 60_000);
|
return ContractTokens.sign(claims, signingKey, 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shapes of item 29: without the claims an Auth from before item 26 needs.
|
|
||||||
private static Map<String, Object> without(Map<String, Object> claims, String... names) {
|
|
||||||
for (String name : names) {
|
|
||||||
claims.remove(name);
|
|
||||||
}
|
|
||||||
return claims;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contractTokens_OfEveryKind_AreAccepted() {
|
void contractTokens_OfEveryKind_AreAccepted() {
|
||||||
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
|
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
|
||||||
@@ -229,20 +224,20 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() {
|
void tokensWithoutAud_AreRefused() {
|
||||||
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id());
|
assertTrue(jwtService.parseLoginToken(sign(withoutAudience(ContractTokens.LOGIN))).isEmpty());
|
||||||
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE)))
|
assertTrue(jwtService.verifyProfileTicket(sign(withoutAudience(ContractTokens.PUBLIC_PROFILE))).isEmpty());
|
||||||
.orElseThrow().userId());
|
assertFalse(jwtService.isServiceToken(sign(withoutAudience(ContractTokens.SERVICE))));
|
||||||
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// The shape of an Auth from before item 26, with the old claims it wrote.
|
||||||
void tokensWithoutTheOldClaims_AreAccepted() {
|
private static Map<String, Object> withoutAudience(String kind) {
|
||||||
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId")))
|
Map<String, Object> claims = ContractTokens.claims(kind);
|
||||||
.orElseThrow().id());
|
claims.remove("aud");
|
||||||
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ")))
|
claims.remove("iss");
|
||||||
.orElseThrow().userId());
|
claims.put("userId", "42");
|
||||||
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ"))));
|
claims.put("typ", kind.equals(ContractTokens.SERVICE) ? "service" : "public-profile");
|
||||||
|
return claims;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import core_service.shared.test.AbstractIntegrationTest;
|
|||||||
import core_service.shared.test.ContractTokens;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@@ -45,6 +43,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
||||||
|
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String serviceToken() {
|
private static String serviceToken() {
|
||||||
@@ -116,12 +115,10 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
|
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The calls as Auth's CoreAccountService sends them, with the service tokens of an old and a new Auth.
|
// The calls as Auth's CoreAccountService sends them.
|
||||||
@ParameterizedTest
|
@Test
|
||||||
@ValueSource(booleans = {false, true})
|
void contractCalls_AreAnswered() throws Exception {
|
||||||
void contractCalls_AreAnswered(boolean legacyToken) throws Exception {
|
String token = ContractTokens.serviceToken();
|
||||||
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
|
|
||||||
: ContractTokens.serviceToken();
|
|
||||||
// replaceDeleted with [] removes every tombstone, also those of other tests in this database.
|
// replaceDeleted with [] removes every tombstone, also those of other tests in this database.
|
||||||
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
|
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -111,10 +111,16 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
void findOrphans_ReturnsUsersNotInTheList() {
|
void findOrphans_ReturnsUsersNotInTheList() {
|
||||||
repository.addDeleted(List.of(7L));
|
repository.addDeleted(List.of(7L));
|
||||||
|
|
||||||
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
|
assertThat(repository.findOrphans(List.of(2L, 8L))).containsExactly(1L, 7L);
|
||||||
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth reads its ids before the call: a user who registers in between has a higher id and is no orphan.
|
||||||
|
@Test
|
||||||
|
void findOrphans_SkipsUsersAboveTheHighestKnownId() {
|
||||||
|
assertThat(repository.findOrphans(List.of(1L))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
|
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
|
||||||
@Test
|
@Test
|
||||||
void tables_CoverEveryTableWithAUserId() {
|
void tables_CoverEveryTableWithAUserId() {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package core_service.modules.account.repo;
|
||||||
|
|
||||||
|
import core_service.modules.account.domain.AccountState;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@Import(AccountStateRepository.class)
|
||||||
|
class AccountStateRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long OLD_STEAM_ID = 76561198000000001L;
|
||||||
|
private static final long NEW_STEAM_ID = 76561198000000002L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountStateRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
jdbc.update("delete from blocked_user");
|
||||||
|
for (long userId : List.of(1L, 2L)) {
|
||||||
|
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
|
||||||
|
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> sync(long userId) {
|
||||||
|
return jdbc.queryForMap("select steam_id, last_synced_at, user_seen_at from user_steam_sync where user_id = ?",
|
||||||
|
userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> blocked() {
|
||||||
|
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
|
||||||
|
|
||||||
|
assertThat(blocked()).containsExactly(1L);
|
||||||
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
|
assertThat(sync(2L).get("user_seen_at")).isNotNull();
|
||||||
|
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
|
||||||
|
|
||||||
|
assertThat(blocked()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
|
||||||
|
@Test
|
||||||
|
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
|
||||||
|
repository.apply(new AccountState(1L, NEW_STEAM_ID, false));
|
||||||
|
|
||||||
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
|
assertThat(sync(1L).get("last_synced_at")).isNull();
|
||||||
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameSteamId_KeepsTheLastSync() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, false));
|
||||||
|
|
||||||
|
assertThat(sync(1L).get("last_synced_at")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noSteamId_StopsTheNightlySync() {
|
||||||
|
repository.apply(new AccountState(1L, null, false));
|
||||||
|
|
||||||
|
assertThat(sync(1L).get("user_seen_at")).isNull();
|
||||||
|
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userWithoutASyncRow_GetsOnlyTheBlock() {
|
||||||
|
repository.apply(new AccountState(3L, NEW_STEAM_ID, true));
|
||||||
|
|
||||||
|
assertThat(blocked()).containsExactly(3L);
|
||||||
|
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
|
||||||
|
repository.apply(new AccountState(1L, OLD_STEAM_ID, true));
|
||||||
|
repository.apply(new AccountState(2L, OLD_STEAM_ID, true));
|
||||||
|
|
||||||
|
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true)));
|
||||||
|
assertThat(blocked()).containsExactly(2L);
|
||||||
|
|
||||||
|
repository.replaceAll(List.of());
|
||||||
|
assertThat(blocked()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import core_service.shared.test.AbstractMockedAdaptersTest;
|
|||||||
import com.jayway.jsonpath.JsonPath;
|
import com.jayway.jsonpath.JsonPath;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -195,6 +197,14 @@ class GameImportControllerTest extends AbstractMockedAdaptersTest {
|
|||||||
verify(igdbApiService, never()).searchGames(any());
|
verify(igdbApiService, never()).searchGames(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"GALAXY", "GROUVEE", "HLTB", "PLAYNITE", "BOOKMARKLET"})
|
||||||
|
void create_ExportAndBookmarkletSources_AreStored(String source) throws Exception {
|
||||||
|
long id = createReady("{\"source\": \"" + source + "\", \"rows\": [{\"title\": \"Elden Ring\"}]}");
|
||||||
|
|
||||||
|
assertEquals(source, jdbc.queryForObject("select source from game_import where id = ?", String.class, id));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_TooManyRowsOrNoTitle_Is400() throws Exception {
|
void create_TooManyRowsOrNoTitle_Is400() throws Exception {
|
||||||
String row = "{\"title\": \"Game\"}";
|
String row = "{\"title\": \"Game\"}";
|
||||||
|
|||||||
@@ -60,88 +60,23 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBacklog_StoresAllFields() throws Exception {
|
void patchBacklog_FractionalRating_Is400() throws Exception {
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
patchBacklog(APPID, "{\"rating\":4.7}").andExpect(status().isBadRequest());
|
||||||
.content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.status").value("FINISHED"))
|
|
||||||
.andExpect(jsonPath("$.rating").value(4));
|
|
||||||
|
|
||||||
UserGame game = stored();
|
|
||||||
assertEquals(GameStatus.FINISHED, game.getStatus());
|
|
||||||
assertEquals(4, game.getRating());
|
|
||||||
assertEquals("Great ending", game.getNotes());
|
|
||||||
assertEquals("HIGH", game.getPriority().name());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBacklog_NullsClearOptionalFieldsAndBlankNotesAreNull() throws Exception {
|
|
||||||
UserGame game = stored();
|
|
||||||
game.setRating(5);
|
|
||||||
game.setNotes("old");
|
|
||||||
userGameRepository.save(game);
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"status\":\"DROPPED\",\"notes\":\" \"}"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
game = stored();
|
|
||||||
assertEquals(GameStatus.DROPPED, game.getStatus());
|
|
||||||
assertNull(game.getRating());
|
|
||||||
assertNull(game.getNotes());
|
|
||||||
assertNull(game.getPriority());
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest
|
|
||||||
@ValueSource(strings = {
|
|
||||||
"{}",
|
|
||||||
"{\"status\":\"WISHLIST\"}",
|
|
||||||
"{\"status\":\"PLAYING\",\"rating\":0}",
|
|
||||||
"{\"status\":\"PLAYING\",\"rating\":6}",
|
|
||||||
"{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"})
|
|
||||||
void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
|
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON).content(body))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
|
|
||||||
assertEquals(GameStatus.BACKLOG, stored().getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBacklog_FractionalRating_Is400() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"status\":\"PLAYING\",\"rating\":4.7}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
|
|
||||||
assertNull(stored().getRating());
|
assertNull(stored().getRating());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBacklog_NotesTooLong_Is400() throws Exception {
|
void patchBacklog_RemovedGame_Is404() throws Exception {
|
||||||
String notes = "x".repeat(2001);
|
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBacklog_RemovedGame_Is404() throws Exception {
|
|
||||||
UserGame game = stored();
|
UserGame game = stored();
|
||||||
game.setRemovedAt(Instant.now());
|
game.setRemovedAt(Instant.now());
|
||||||
userGameRepository.save(game);
|
userGameRepository.save(game);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
patchBacklog(APPID, "{\"notes\":\"x\"}")
|
||||||
.content("{\"status\":\"PLAYING\"}"))
|
|
||||||
.andExpect(status().isNotFound())
|
.andExpect(status().isNotFound())
|
||||||
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBacklog_GameNotOwned_Is404() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/library/games/999/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"status\":\"PLAYING\"}"))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
private ResultActions patchBacklog(long appid, String body) throws Exception {
|
private ResultActions patchBacklog(long appid, String body) throws Exception {
|
||||||
return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(body));
|
.contentType(MediaType.APPLICATION_JSON).content(body));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import java.util.List;
|
|||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
import static org.hamcrest.Matchers.nullValue;
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
// The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200.
|
// The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200.
|
||||||
@Test
|
@Test
|
||||||
void profileTicket_IsNoLoginForTheLibraryApi() throws Exception {
|
void profileTicket_IsNoLoginForTheLibraryApi() throws Exception {
|
||||||
mockMvc.perform(put("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID))
|
mockMvc.perform(patch("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID))
|
||||||
.contentType("application/json").content("{\"status\":\"DROPPED\"}"))
|
.contentType("application/json").content("{\"status\":\"DROPPED\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameNamesTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
|
||||||
|
assertThat(GameNames.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
|
||||||
|
assertThat(GameNames.normalize("The Witcher 3: Wild Hunt")).isEqualTo(GameNames.normalize("The Witcher® 3 - Wild Hunt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,14 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(claim(3L)).isZero();
|
assertThat(claim(3L)).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tryClaimRun_RefusesABlockedUser() {
|
||||||
|
repository.saveAndFlush(new UserSteamSync(3L, STEAM_ID));
|
||||||
|
jdbc.update("insert into blocked_user (user_id) values (3)");
|
||||||
|
|
||||||
|
assertThat(claim(3L)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
|
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
|
||||||
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
|
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ class SpeedrunMatchTest extends AbstractUnitTest {
|
|||||||
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
||||||
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
||||||
|
|
||||||
@Test
|
|
||||||
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
|
|
||||||
assertThat(SpeedrunMatch.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
|
|
||||||
assertThat(SpeedrunMatch.normalize("The Witcher 3: Wild Hunt")).isEqualTo(SpeedrunMatch.normalize("The Witcher® 3 - Wild Hunt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_YearSuffixTellsTheRemakeApart() {
|
void candidates_YearSuffixTellsTheRemakeApart() {
|
||||||
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
|
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
|
||||||
|
|||||||
@@ -76,4 +76,15 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
hiddenGames_LeaveEveryNumber();
|
hiddenGames_LeaveEveryNumber();
|
||||||
hiddenGames_LeaveTheTopLists();
|
hiddenGames_LeaveTheTopLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blockedUsers_LeaveEveryNumberAndTheTopLists() {
|
||||||
|
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
||||||
|
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
||||||
|
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
|
||||||
|
|
||||||
|
hiddenGames_LeaveEveryNumber();
|
||||||
|
hiddenGames_LeaveTheTopLists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -108,7 +108,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
||||||
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(1,
|
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
|
||||||
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
||||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||||
.thenAnswer(invocation -> {
|
.thenAnswer(invocation -> {
|
||||||
|
|||||||
@@ -45,27 +45,6 @@ class SteamApiServiceTest {
|
|||||||
ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key");
|
ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getOwnedGamesCount_shouldReturnCount() {
|
|
||||||
String steamId = "76561197960435530";
|
|
||||||
String jsonResponse = """
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"game_count": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
|
||||||
.andExpect(queryParam("steamid", steamId))
|
|
||||||
.andExpect(queryParam("key", "test-api-key"))
|
|
||||||
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
int result = steamApiService.getOwnedGamesCount(steamId);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(42);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getPlayerSummary_shouldReturnPlayer() {
|
void getPlayerSummary_shouldReturnPlayer() {
|
||||||
String steamId = "76561197960435530";
|
String steamId = "76561197960435530";
|
||||||
@@ -135,15 +114,6 @@ class SteamApiServiceTest {
|
|||||||
assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1);
|
assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getOwnedGamesCount_shouldCountUnvettedApps() {
|
|
||||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
|
||||||
.andExpect(queryParam("skip_unvetted_apps", "0"))
|
|
||||||
.andRespond(withSuccess("{\"response\":{\"game_count\":1}}", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
assertThat(steamApiService.getOwnedGamesCount("76561197960435530")).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
|
void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
|
||||||
String steamId = "76561197960435530";
|
String steamId = "76561197960435530";
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package core_service.shared.test;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Tag;
|
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for WebMvc tests.
|
|
||||||
* Slices the web layer.
|
|
||||||
*/
|
|
||||||
@Tag("WebMvcTest")
|
|
||||||
@WebMvcTest
|
|
||||||
public abstract class AbstractWebMvcTest {
|
|
||||||
}
|
|
||||||
@@ -46,14 +46,6 @@ public final class ContractTokens {
|
|||||||
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
|
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The claims of an Auth from before item 26.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public static Map<String, Object> legacyClaims(String kind) {
|
|
||||||
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("legacyTokens").required(kind), Map.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static JsonNode internalCall(String name) {
|
public static JsonNode internalCall(String name) {
|
||||||
return CONTRACT.path("internal").required(name);
|
return CONTRACT.path("internal").required(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
|
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out.",
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"login": {
|
"login": {
|
||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-login"],
|
"aud": ["tbp-login"],
|
||||||
"sub": "42",
|
"sub": "42",
|
||||||
"userId": "42",
|
|
||||||
"role": "USER",
|
"role": "USER",
|
||||||
"steamId": 76561198000000001
|
"steamId": 76561198000000001
|
||||||
},
|
},
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-public-profile"],
|
"aud": ["tbp-public-profile"],
|
||||||
"sub": "public-profile",
|
"sub": "public-profile",
|
||||||
"typ": "public-profile",
|
|
||||||
"profileUserId": "42",
|
"profileUserId": "42",
|
||||||
"profileStats": ["OVERVIEW"],
|
"profileStats": ["OVERVIEW"],
|
||||||
"sections": ["SUMMARY"]
|
"sections": ["SUMMARY"]
|
||||||
@@ -21,27 +19,7 @@
|
|||||||
"service": {
|
"service": {
|
||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-internal"],
|
"aud": ["tbp-internal"],
|
||||||
"sub": "tbp-auth",
|
"sub": "tbp-auth"
|
||||||
"typ": "service"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"legacyTokens": {
|
|
||||||
"login": {
|
|
||||||
"sub": "user@example.com",
|
|
||||||
"userId": "42",
|
|
||||||
"role": "USER",
|
|
||||||
"steamId": 76561198000000001
|
|
||||||
},
|
|
||||||
"publicProfile": {
|
|
||||||
"sub": "public-profile",
|
|
||||||
"typ": "public-profile",
|
|
||||||
"profileUserId": "42",
|
|
||||||
"profileStats": ["OVERVIEW"],
|
|
||||||
"sections": ["SUMMARY"]
|
|
||||||
},
|
|
||||||
"service": {
|
|
||||||
"sub": "tbp-auth",
|
|
||||||
"typ": "service"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"internal": {
|
"internal": {
|
||||||
@@ -55,6 +33,16 @@
|
|||||||
"path": "/internal/accounts/deleted",
|
"path": "/internal/accounts/deleted",
|
||||||
"request": {"userIds": []}
|
"request": {"userIds": []}
|
||||||
},
|
},
|
||||||
|
"applyStates": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/accounts/states",
|
||||||
|
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
|
||||||
|
},
|
||||||
|
"replaceStates": {
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/internal/accounts/states",
|
||||||
|
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
|
||||||
|
},
|
||||||
"purge": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/purge",
|
"path": "/internal/accounts/purge",
|
||||||
|
|||||||
Reference in new issue
Block a user