diff --git a/app/repositories/patcher_token_logs.py b/app/repositories/patcher_token_logs.py new file mode 100644 index 0000000..fbd64e1 --- /dev/null +++ b/app/repositories/patcher_token_logs.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +import app.state + + +class PatcherTokenLog(BaseModel): + id: str + score_id: int + client_hash: str + commentary: str | None + token_generated_at: datetime + created_at: datetime + updated_at: datetime + + +READ_PARAMS = """\ + id, score_id, client_hash, commentary, token_generated_at, created_at, updated_at +""" + + +async def delete_many_by_user_id_via_scores_tables( + user_id: int, + /, +) -> list[PatcherTokenLog]: + query = f"""\ + WITH score_ids AS ( + SELECT id FROM scores WHERE user_id = :user_id + UNION + SELECT id FROM scores_relax WHERE user_id = :user_id + UNION + SELECT id FROM scores_ap WHERE user_id = :user_id + ) + SELECT {READ_PARAMS} + FROM patcher_token_logs + WHERE score_id IN score_ids + """ + params: dict[str, Any] = {"user_id": user_id} + recs = await app.state.database.fetch_all(query, params) + if not recs: + return [] + + query = """\ + WITH score_ids AS ( + SELECT id FROM scores WHERE user_id = :user_id + UNION + SELECT id FROM scores_relax WHERE user_id = :user_id + UNION + SELECT id FROM scores_ap WHERE user_id = :user_id + ) + DELETE FROM patcher_token_logs + WHERE score_id IN score_ids + """ + params = {"user_id": user_id} + await app.state.database.execute(query, params) + + return [ + PatcherTokenLog( + id=rec["id"], + score_id=rec["score_id"], + client_hash=rec["client_hash"], + commentary=rec["commentary"], + token_generated_at=rec["token_generated_at"], + created_at=rec["created_at"], + updated_at=rec["updated_at"], + ) + for rec in recs + ] diff --git a/app/repositories/score_submission_logs.py b/app/repositories/score_submission_logs.py new file mode 100644 index 0000000..2e4a612 --- /dev/null +++ b/app/repositories/score_submission_logs.py @@ -0,0 +1,76 @@ +from datetime import datetime +from datetime import time +from typing import Any + +from pydantic import BaseModel + +import app.state + + +class ScoreSubmissionLog(BaseModel): + id: str + score_id: int + uninstall_id_hash: str + disk_signature_hash: str + client_version: str + client_hash: str + score_time_elapsed: time + osu_auth_token: str | None + created_at: datetime + + +READ_PARAMS = """\ + id, score_id, uninstall_id_hash, disk_signature_hash, client_version, + client_hash, score_time_elapsed, osu_auth_token, created_at +""" + + +async def delete_many_by_user_id_via_scores_tables( + user_id: int, + /, +) -> list[ScoreSubmissionLog]: + query = f"""\ + WITH score_ids AS ( + SELECT id FROM scores WHERE user_id = :user_id + UNION + SELECT id FROM scores_relax WHERE user_id = :user_id + UNION + SELECT id FROM scores_ap WHERE user_id = :user_id + ) + SELECT {READ_PARAMS} + FROM score_submission_logs + WHERE score_id IN score_ids + """ + params: dict[str, Any] = {"user_id": user_id} + recs = await app.state.database.fetch_all(query, params) + if not recs: + return [] + + query = """\ + WITH score_ids AS ( + SELECT id FROM scores WHERE user_id = :user_id + UNION + SELECT id FROM scores_relax WHERE user_id = :user_id + UNION + SELECT id FROM scores_ap WHERE user_id = :user_id + ) + DELETE FROM score_submission_logs + WHERE score_id IN score_ids + """ + params = {"user_id": user_id} + await app.state.database.execute(query, params) + + return [ + ScoreSubmissionLog( + id=rec["id"], + score_id=rec["score_id"], + uninstall_id_hash=rec["uninstall_id_hash"], + disk_signature_hash=rec["disk_signature_hash"], + client_version=rec["client_version"], + client_hash=rec["client_hash"], + score_time_elapsed=rec["score_time_elapsed"], + osu_auth_token=rec["osu_auth_token"], + created_at=rec["created_at"], + ) + for rec in recs + ] diff --git a/app/usecases/users.py b/app/usecases/users.py index 1a4d46b..ac8191b 100644 --- a/app/usecases/users.py +++ b/app/usecases/users.py @@ -12,6 +12,8 @@ from app.repositories import clans from app.repositories import lastfm_flags from app.repositories import password_recovery +from app.repositories import patcher_token_logs +from app.repositories import score_submission_logs from app.repositories import user_badges from app.repositories import user_hwid_associations from app.repositories import user_ip_associations @@ -212,15 +214,15 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: # - [leave as-is] users_stats # - [leave as-is] rx_stats # - [leave as-is] ap_stats - # - [TODO/AC] ip_user - # - [TODO/AC] hw_user + # - [delete for now; TODO anonymize] ip_user + # - [delete for now; TODO anonymize] hw_user # - [leave as-is] user_badges # - [leave as-is] user_tourmnt_badges # - [leave as-is] user_achievements # - [transfer perms if owner & kick] clans # - [leave as-is] identity_tokens # - [leave as-is] irc_tokens - # - [TODO/AC] lastfm_flags + # - [delete] lastfm_flags # - [leave as-is] beatmaps_rating # - [leave as-is] clan_requests (empty?) # - [leave as-is] comments @@ -228,10 +230,10 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: # - [leave as-is] match_events # - [leave as-is] match_games # - [leave as-is] match_game_scores - # - [TODO/financial] notifications + # - [leave-as-is (FINANCE)] notifications # - [delete; key'd by username??] password_recovery - # - [TODO/AC] patcher_detections - # - [TODO/AC] patcher_token_logs + # - [delete] patcher_detections + # - [delete] patcher_token_logs # - [TODO] profile_backgrounds (and filesystem data) # - [TODO] rap_logs # - [leave as-is] remember @@ -244,7 +246,7 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: # - [leave as-is] scores_ap # - [leave as-is] scores_relax # - [leave as-is] scores_first - # - [TODO/AC] score_submission_logs + # - [delete] score_submission_logs # - [leave as-is] tokens # - [leave as-is] user_relationships # - [leave as-is] user_beatmaps @@ -256,6 +258,8 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: # misc. # - [anonymize] replay data for all scores # - [TODO] youtube uploads + # - [TODO] static content (screenshots, profile bgs, etc.) + # - [TODO] database/fs backups older than 50 days # PII to focus on: # - username / username aka @@ -302,7 +306,10 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: await user_ip_associations.delete_many_by_user_id(user_id) await user_hwid_associations.delete_many_by_user_id(user_id) await lastfm_flags.delete_many_by_user_id(user_id) - # TODO: patcher_detections & patcher_token_logs + + await score_submission_logs.delete_many_by_user_id_via_scores_tables(user_id) + await patcher_token_logs.delete_many_by_user_id_via_scores_tables(user_id) + # TODO: delete user records from `patcher_detections` table as well # TODO: wipe or anonymize all replay data. # probably a good idea to call scores-service @@ -315,7 +322,7 @@ async def delete_one_by_user_id(user_id: int, /) -> None | Error: # at the usecase layer await users.anonymize_one_by_user_id(user_id) - # TODO: (technically required) anonymize data in data backups + # TODO: don't store backups older than 50 days via sql-backup-job # inform other systems of the user's deletion (or "ban") await app.state.redis.publish("peppy:ban", str(user_id))