129 Commits

Author SHA1 Message Date
f01b2a24e5 chore(deps): update eclipse-temurin docker tag to v17.0.13_11-jre-focal 2024-10-24 04:02:37 +00:00
Lee
f24b907b1a Merge pull request 'fix(deps): update dependency org.jetbrains:annotations to v25' (#17) from renovate/org.jetbrains-annotations-25.x into master
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 11s
Reviewed-on: #17
2024-09-25 23:49:35 +00:00
88e8c4d3e8 fix(deps): update dependency org.jetbrains:annotations to v25 2024-09-25 08:01:40 +00:00
7b560075ba mod: cleanup auth
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 35s
Release Mod / Build (push) Successful in 30s
2024-08-15 18:22:16 +01:00
d888ab1eb5 Merge remote-tracking branch 'origin/master'
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m21s
2024-08-09 00:52:04 +01:00
8371344a7d change account refresh interval 2024-08-09 00:51:36 +01:00
Lee
ea0e4bd20a Merge pull request 'Update actions/checkout action to v4' (#10) from renovate/actions-checkout-4.x into master
All checks were successful
Release Mod / Build (push) Successful in 28s
Reviewed-on: #10
2024-08-07 07:44:10 +00:00
Lee
02290e5bde Merge pull request 'Update actions/setup-dotnet action to v4' (#11) from renovate/actions-setup-dotnet-4.x into master
Some checks failed
Release Mod / Build (push) Has been cancelled
Reviewed-on: #11
2024-08-07 07:44:00 +00:00
Lee
ef2aeb5f0a Merge pull request 'Update dependency BepInEx.AssemblyPublicizer.MSBuild to v0.4.2' (#12) from renovate/bepinex.assemblypublicizer.msbuild-0.x into master
Some checks failed
Release Mod / Build (push) Has been cancelled
Reviewed-on: #12
2024-08-07 07:43:47 +00:00
8e3a46f8bc mod: impl config for api url
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 08:26:12 +01:00
456f2afff0 Update actions/setup-dotnet action to v4 2024-08-07 07:01:18 +00:00
e0eda1a053 Update actions/checkout action to v4 2024-08-07 07:01:17 +00:00
9355368f54 update api url
All checks were successful
Release Mod / Build (push) Successful in 29s
2024-08-07 07:50:44 +01:00
c2e0aafda6 this will do for now im too tired
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 07:46:33 +01:00
33b1c0357b yes
Some checks failed
Release Mod / Build (push) Failing after 35s
2024-08-07 07:37:51 +01:00
0ae7cdb42c yes
Some checks failed
Release Mod / Build (push) Failing after 53s
2024-08-07 07:36:24 +01:00
55f1b2fc8d yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:34:46 +01:00
3b005510e4 yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:33:43 +01:00
88eea39a2e yes
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 07:31:23 +01:00
728d987b64 yes
Some checks failed
Release Mod / Build (push) Failing after 32s
2024-08-07 07:26:33 +01:00
c2044c5f80 yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:20:45 +01:00
41e248b751 yes
Some checks failed
Release Mod / Build (push) Failing after 3s
2024-08-07 07:19:17 +01:00
13840aa1e2 yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:15:36 +01:00
02e8946b9b yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:12:04 +01:00
fcf3785477 yes
Some checks failed
Release Mod / Build (push) Failing after 4s
2024-08-07 07:11:13 +01:00
135c34f763 yes
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 07:07:22 +01:00
21c6abb443 yes
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 07:04:37 +01:00
f9b95744f8 Update dependency BepInEx.AssemblyPublicizer.MSBuild to v0.4.2 2024-08-07 06:01:08 +00:00
4d95e0ee53 yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:00:57 +01:00
5b7de4150c yes
Some checks failed
Release Mod / Build (push) Failing after 30s
2024-08-07 06:57:27 +01:00
b3ae9ca369 yes
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 06:54:45 +01:00
eba857829b yes
Some checks failed
Release Mod / Build (push) Failing after 8s
2024-08-07 06:22:18 +01:00
7253bf2eea yes
Some checks failed
Release Mod / Build (push) Failing after 8s
2024-08-07 06:19:56 +01:00
067666ef7c yes
Some checks failed
Release Mod / Build (push) Failing after 5s
2024-08-07 06:18:27 +01:00
06927f3fd3 yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 06:16:32 +01:00
cb1ee3abad yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 06:07:37 +01:00
fa60278463 yes
Some checks failed
Release Mod / Build (push) Failing after 22s
2024-08-07 06:05:32 +01:00
e397c86963 yes
Some checks failed
Release Mod / Build (push) Failing after 32s
2024-08-07 06:03:46 +01:00
1bcb99430c yes
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 05:53:01 +01:00
07c6bc3d0a yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:49:11 +01:00
c62948627c yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:47:47 +01:00
0287f4f83a yes
Some checks failed
Release Mod / Build (push) Failing after 23s
2024-08-07 05:45:21 +01:00
7307ccc99b yes
Some checks failed
Release Mod / Build (push) Failing after 22s
2024-08-07 05:39:34 +01:00
03bfcab616 ci
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 05:36:40 +01:00
53f407081e ci
Some checks failed
Release Mod / Build (push) Failing after 3s
2024-08-07 05:36:10 +01:00
eab307882e ci
Some checks failed
Release Mod / Build (push) Failing after 19s
2024-08-07 05:34:16 +01:00
aa23919037 ci
Some checks failed
Release Mod / Build (push) Failing after 18s
2024-08-07 05:33:24 +01:00
e27258a351 ci
Some checks failed
Release Mod / Build (push) Failing after 4s
2024-08-07 05:32:40 +01:00
577c8e7deb ci
Some checks failed
Release Mod / Build (push) Failing after 20s
2024-08-07 05:31:11 +01:00
2f24c529e0 oopsie
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:30:00 +01:00
517e9df72b ci
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 05:28:21 +01:00
f38a1156e1 ci 2024-08-07 05:27:54 +01:00
c04a51de35 why
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m32s
2024-08-07 05:19:39 +01:00
1ec8248c6f IMPL BASIC AUTH INTO MOD AND BACKEND
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Has been cancelled
2024-08-07 05:19:24 +01:00
05e1c7170d inital commit 2024-08-06 23:57:47 +01:00
bd31254990 api: track player histories more often to keep them more up to date
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m4s
2024-08-06 21:12:59 +01:00
131a5c2efe api: optimize pagination!!
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 08:53:07 +01:00
1f1c55d41f api: make items cache for longer
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 08:37:49 +01:00
21b6de0f15 api: impl pagination for top scores endpoint and add user + leaderboard caching
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s
2024-08-05 08:35:16 +01:00
64b6ef1a7f api: oopsie doodle
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 05:59:41 +01:00
54bdf532fe api: add fallback values for hmd identification
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-05 05:57:50 +01:00
6cb86f843d cleanup score history
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 05:48:01 +01:00
f75d22fa58 api: update method comment
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-05 05:01:18 +01:00
68ce2ff240 api: add possiblyInaccurateData field to show if the history for that day could be inaccurate
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:58:46 +01:00
ba24eabfaa api: since we have this, might aswell track it, but it won't be be accurate if they ether: don't set a score that day, or only set 1 score in the morning, but it's better than no data for that day
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:32:06 +01:00
84eb8a4b94 api: don't fetch all data when tracking player metrics
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:25:34 +01:00
6f49d81664 api: handle player not setting scores for the day
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 29s
2024-08-05 04:18:48 +01:00
98223a3293 api: change how some data points are got for histories
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-05 04:15:01 +01:00
7b0c9f54ff api: re-impl histories (muchhhhhhhhhh better now)
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-05 03:57:59 +01:00
29f5d5983a rename statistics to histories
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 34s
2024-08-04 04:25:51 +01:00
8c354b1be1 api: make log less fat
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m8s
2024-08-04 01:34:19 +01:00
de309ea05c api: add /improved/best/{platform} endpoint
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-04 00:36:59 +01:00
199ee50534 api: add score history endpoints (per leaderboard and last 30 day improvements (all maps))
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s
2024-08-04 00:32:39 +01:00
6fda81e81a oopsie
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m5s
2024-08-03 20:24:37 +01:00
bdad804eed api: oopsie doodle
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-02 19:28:30 +01:00
66d29c343e api: fix mongo indexes
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 32s
2024-08-02 18:02:43 +01:00
96f62d9a01 api: change how scores are stored
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 34s
2024-08-02 17:36:24 +01:00
4697cd4aec api: return the last updated
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-02 02:51:58 +01:00
0abff880c2 API: make the pp slightly more accurate (revert)
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 32s
2024-08-02 02:50:12 +01:00
cc351e6cad API: make the pp slightly more accurate
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-02 02:25:33 +01:00
f351c7a3c1 API: don't return the whole user
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m21s
2024-08-02 01:54:50 +01:00
68180f2647 api: oops
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 5s
2024-08-02 00:38:27 +01:00
6a1a2dc2c4 API: fix stats
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Has been cancelled
2024-08-02 00:38:08 +01:00
d4e51d1517 api: rename has logged in field
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-02 00:07:13 +01:00
02fcaf19eb api: don't return the user early
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-01 23:50:37 +01:00
0f307eb18c api: oopsie again
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-01 23:48:45 +01:00
357315990e API: oopsie
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-01 23:46:09 +01:00
de89182c5d impl user history tracking
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 14s
2024-08-01 23:44:20 +01:00
8dfdc8c535 API: switch to Mongo for score tracking
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 39s
2024-08-01 23:24:34 +01:00
f68fb48726 API: show the whole user
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:44:32 +01:00
7b1d4a73a5 API: move around
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:35:10 +01:00
139b3bf06d API: hide duplicated difficulty
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-01 16:32:09 +01:00
20576d913f API: don't return the lastUpdate field
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-01 16:30:12 +01:00
871ae76a23 fix leaderboard object order
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:26:57 +01:00
7aa3de3827 move where the accounts get updated
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 45s
2024-08-01 16:25:21 +01:00
4135af4743 Merge remote-tracking branch 'origin/master'
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 40s
2024-08-01 16:19:43 +01:00
242a8a2fba store the users scoresaber profile and add more data to the top scores endpoint 2024-08-01 16:19:27 +01:00
Lee
38c7bfcd3d Merge pull request 'chore(deps): update dependency @types/node to v22' (#6) from renovate/node-22.x into master
Some checks failed
Deploy Frontend / docker (push) Failing after 1m7s
Reviewed-on: #6
2024-08-01 13:17:14 +00:00
e4ec89a7d6 chore(deps): update dependency @types/node to v22 2024-08-01 06:01:37 +00:00
0f7a890e44 return the user DTO instead of the full user
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-01 06:55:42 +01:00
1a13c08da8 api: add / route
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 35s
2024-08-01 06:45:51 +01:00
bd4b042c3d frontend: change title
Some checks failed
Deploy Frontend / docker (push) Failing after 47s
2024-08-01 06:41:29 +01:00
ebeaf60d5a frontend: remove boilderplate stuff
Some checks failed
Deploy Frontend / docker (push) Failing after 48s
2024-08-01 06:37:16 +01:00
cfd2ae004d frontend testing
Some checks failed
Deploy Frontend / docker (push) Failing after 1m50s
2024-08-01 06:29:39 +01:00
86167695d8 API: fix ci
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 21s
2024-08-01 06:21:32 +01:00
8860a46462 shrug emoji
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 41s
2024-08-01 06:15:58 +01:00
008ca0f6d4 maybe?
Some checks failed
Deploy API / docker (ubuntu-latest) (push) Failing after 8s
2024-08-01 06:12:51 +01:00
38b91e6442 ci testing
Some checks failed
Deploy API / docker (ubuntu-latest) (push) Failing after 8s
2024-08-01 06:05:52 +01:00
cdba187bc1 fix ci
Some checks failed
Deploy API / docker (ubuntu-latest) (push) Failing after 9s
2024-08-01 06:02:27 +01:00
f0c5f0bdbd oopsie
Some checks failed
Deploy API / docker (ubuntu-latest) (push) Failing after 10s
2024-08-01 06:00:57 +01:00
47fc07f2dd mono repo base 2024-08-01 05:57:27 +01:00
90ec976939 increase top scores to 100 from 50
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 35s
2024-08-01 02:25:36 +01:00
a5a69ae979 fix print 2024-08-01 02:18:01 +01:00
58bdc6f414 add missing leaderboards
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 37s
2024-08-01 02:13:34 +01:00
4e828f2c2b cleanup ranked score updating
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 50s
2024-08-01 02:00:47 +01:00
1d4e19bd3c fix npe
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 36s
2024-08-01 01:24:32 +01:00
560fce9b01 meow
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 39s
2024-08-01 01:22:13 +01:00
3f5eff3d1a oh? x.x
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 35s
2024-08-01 01:16:07 +01:00
900ccba67e add check
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 37s
2024-08-01 01:12:23 +01:00
09834e9eac fix
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 41s
2024-08-01 01:10:08 +01:00
1efab2ed08 maybe fixed pp? honestly, no idea
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 1m43s
2024-08-01 01:00:34 +01:00
49e223a8b9 add rate limit handler
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 34s
2024-07-31 23:24:37 +01:00
6b11a608bd maybe fix pp
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 36s
2024-07-31 23:02:54 +01:00
e7ea152bf9 maybe fix disconnects from websockets?
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 48s
2024-07-31 22:49:49 +01:00
d3268769e1 only update the scores if it's required
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 41s
2024-07-27 21:05:33 +01:00
8601915f76 Merge remote-tracking branch 'origin/master'
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 37s
2024-07-27 20:26:43 +01:00
ef4cbc4015 change top to top 50 2024-07-27 20:26:28 +01:00
Lee
99d545d81f Merge pull request 'Update dependency org.questdb:questdb to v8.1.0' (#2) from renovate/org.questdb-questdb-8.x into master
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 37s
Reviewed-on: Fascinated/yet-another-bs-tracker-java#2
2024-07-27 19:22:36 +00:00
ce5b6c83ee Update dependency org.questdb:questdb to v8.1.0 2024-07-24 20:02:29 +00:00
121 changed files with 4729 additions and 1189 deletions

View File

@ -1,31 +1,30 @@
name: Deploy to Dokku
name: Deploy API
on:
push:
branches: ["master"]
paths-ignore:
- .gitignore
- README.md
- LICENSE
paths: [".gitea/workflows/deploy-api.yml", "API/**"]
jobs:
docker:
strategy:
matrix:
arch: ["ubuntu-latest"]
runs-on: ${{ matrix.arch }}
java-version: ["17"]
maven-version: ["3.8.5"]
runs-on: ubuntu-latest
defaults:
run:
working-directory: "./API"
# Steps to run
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Deploy to Dokku
- name: Push to dokku
- name: Deploy to Dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

View File

@ -0,0 +1,26 @@
name: Deploy Frontend
on:
push:
branches: ["master"]
paths: [".gitea/workflows/deploy-frontend.yml", "Frontend/**"]
jobs:
docker:
runs-on: ubuntu-latest
defaults:
run:
working-directory: "./Frontend"
# Steps to run
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v4
# Deploy to Dokku
- name: Deploy to Dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker-frontend"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

View File

@ -0,0 +1,49 @@
name: Release Mod
on:
push:
branches: [master]
paths: [".gitea/workflows/release-mod.yml", "Mod/**"]
jobs:
Build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: "./Mod"
steps:
- uses: actions/checkout@v4
- name: Setup Running in CI Variable
run: echo "RUNNING_IN_CI=true" >> $GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 7.0.x
- name: Initialize modding environment
uses: beat-forge/init-beatsaber@v1
with:
repo: beat-forge/beatsaber-stripped
- name: Download Mod Dependencies
uses: Goobwabber/download-beatmods-deps@1.2
with:
manifest: ${{ gitea.workspace }}/Mod/manifest.json
- name: Build
id: Build
run: dotnet build ScoreTracker.csproj --configuration Release
- name: Echo Filename
run: echo $BUILDTEXT \($ASSEMBLYNAME\)
env:
BUILDTEXT: Filename=${{ steps.Build.outputs.filename }}
ASSEMBLYNAME: AssemblyName=${{ steps.Build.outputs.assemblyname }}
- name: Upload Artifact
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ steps.Build.outputs.filename }}
path: ${{ steps.Build.outputs.artifactpath }}

33
.gitignore vendored
View File

@ -1,31 +1,2 @@
*.class
*.log
*.ctxt
.mtj.tmp/
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
replay_pid*
.idea
cmake-build-*/
.idea/**/mongoSettings.xml
*.iws
out/
build/
work/
target/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
git.properties
pom.xml.versionsBackup
/docker/questdb/
/docker/mongodb/
docker
.idea

3
.idea/.gitignore generated vendored
View File

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

7
.idea/encodings.xml generated
View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>

14
.idea/misc.xml generated
View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="azul-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

7
.idea/vcs.xml generated
View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/docker" vcs="Git" />
</component>
</project>

29
API/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
*.class
*.log
*.ctxt
.mtj.tmp/
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
replay_pid*
.idea
cmake-build-*/
.idea/**/mongoSettings.xml
*.iws
out/
build/
work/
target/
.idea_modules/
atlassian-ide-plugin.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
git.properties
pom.xml.versionsBackup

View File

@ -11,7 +11,7 @@ COPY . .
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
# Stage 2: Create the final lightweight image
FROM eclipse-temurin:17.0.12_7-jre-focal
FROM eclipse-temurin:17.0.13_11-jre-focal
# Set the working directory
WORKDIR /home/container

View File

@ -62,10 +62,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
@ -74,6 +70,20 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Dependencies -->
<dependency>
@ -85,13 +95,9 @@
<artifactId>unirest-modules-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb</artifactId>
<version>8.0.3</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<groupId>net.jodah</groupId>
<artifactId>expiringmap</artifactId>
<version>0.5.11</version>
</dependency>
<!-- Libraries -->
@ -110,7 +116,7 @@
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>24.1.0</version>
<version>25.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@ -6,8 +6,8 @@ import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.io.File;
@ -18,11 +18,11 @@ import java.util.Objects;
/**
* @author Fascinated (fascinated7)
*/
@EnableJpaRepositories(basePackages = "cc.fascinated.repository.couchdb")
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
@EnableScheduling
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
@EnableRedisRepositories(basePackages = "cc.fascinated.repository.redis")
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@Log4j2(topic = "Ember")
@Log4j2(topic = "Score Tracker")
public class Main {
@SneakyThrows
public static void main(@NonNull String[] args) {

View File

@ -0,0 +1,90 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Locale;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class DateUtils {
private static final ZoneId ZONE_ID = ZoneId.of("Europe/London");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT
.withLocale(Locale.UK)
.withZone(ZONE_ID);
private static final DateTimeFormatter SIMPLE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withLocale(Locale.UK)
.withZone(ZONE_ID);
/**
* Gets the date from a string.
*
* @param date The date string.
* @return The date.
*/
public static Date getDateFromIsoString(String date) {
return Date.from(Instant.from(ISO_FORMATTER.parse(date)));
}
/**
* Gets the date from a string.
*
* @param date The date string.
* @return The date.
*/
public static Date getDateFromString(String date) {
LocalDate localDate = LocalDate.parse(date, SIMPLE_FORMATTER);
ZonedDateTime zonedDateTime = localDate.atStartOfDay(ZONE_ID);
return Date.from(zonedDateTime.toInstant());
}
/**
* Formats a date to a string.
*
* @param date The date to format.
* @return The formatted date.
*/
public String formatDate(Date date) {
return SIMPLE_FORMATTER.format(date.toInstant());
}
/**
* Aligns the date to the current hour.
* <p>
* eg: 00:05 -> 00:00
* </p>
*
* @param date The date to align.
* @return The aligned date.
*/
public static Date alignToCurrentHour(Date date) {
return Date.from(Instant.ofEpochMilli(date.getTime()).truncatedTo(ChronoUnit.HOURS));
}
/**
* Gets the date from an amount of days ago.
*
* @param days The amount to go back.
* @return The date.
*/
public static Date getDaysAgo(int days) {
return Date.from(Instant.now().minus(days, ChronoUnit.DAYS));
}
/**
* Gets the date for midnight today.
*
* @return The date.
*/
public static Date getMidnightToday() {
return Date.from(Instant.now().truncatedTo(ChronoUnit.DAYS));
}
}

View File

@ -0,0 +1,24 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class EnumUtils {
/**
* Gets the name of the enum
*
* @param e the enum
* @return the name
*/
public static String getEnumName(Enum<?> e) {
String[] split = e.name().split("_");
StringBuilder builder = new StringBuilder();
for (String s : split) {
builder.append(s.substring(0, 1).toUpperCase()).append(s.substring(1).toLowerCase()).append(" ");
}
return builder.toString().trim();
}
}

View File

@ -0,0 +1,54 @@
package cc.fascinated.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public final class IPUtils {
/**
* The regex expression for validating IPv4 addresses.
*/
public static final String IPV4_REGEX = "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$";
/**
* The regex expression for validating IPv6 addresses.
*/
public static final String IPV6_REGEX = "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?$";
private static final String[] IP_HEADERS = new String[] {
"CF-Connecting-IP",
"X-Forwarded-For"
};
/**
* Get the real IP from the given request.
*
* @param request the request
* @return the real IP
*/
@NonNull
public static String getRealIp(@NonNull HttpServletRequest request) {
String ip = request.getRemoteAddr();
for (String headerName : IP_HEADERS) {
String header = request.getHeader(headerName);
if (header == null) {
continue;
}
if (!header.contains(",")) { // Handle single IP
ip = header;
break;
}
// Handle multiple IPs
String[] ips = header.split(",");
for (String ipHeader : ips) {
ip = ipHeader;
break;
}
}
return ip;
}
}

View File

@ -2,6 +2,9 @@ package cc.fascinated.common;
import lombok.experimental.UtilityClass;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
/**
* @author Fascinated (fascinated7)
*/
@ -31,4 +34,19 @@ public class MathUtils {
public static double lerp(double a, double b, double t) {
return a + t * (b - a);
}
/**
* Format a number to a specific amount of decimal places.
*
* @param number the number to format
* @param additional the additional decimal places to format
* @return the formatted number
*/
public static double format(double number, int additional) {
return Double.parseDouble(
new DecimalFormat("#.#" + "#".repeat(Math.max(0, additional - 1)),
new DecimalFormatSymbols()
).format(number)
);
}
}

View File

@ -0,0 +1,145 @@
package cc.fascinated.common;
import cc.fascinated.exception.impl.BadRequestException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class PaginationBuilder<T> {
/**
* The number of items per page.
*/
private int itemsPerPage;
/**
* The total number of items.
*/
private int totalItems;
/**
* The items to paginate.
*/
private Function<FetchItems, List<T>> items;
/**
* Sets the number of items per page.
*
* @param itemsPerPage The number of items per page.
* @return The pagination builder.
*/
public PaginationBuilder<T> itemsPerPage(int itemsPerPage) {
this.itemsPerPage = itemsPerPage;
return this;
}
/**
* Sets the total number of items.
*
* @param totalItems The total number of items.
* @return The pagination builder.
*/
public PaginationBuilder<T> totalItems(Supplier<Integer> totalItems) {
this.totalItems = totalItems.get();
return this;
}
/**
* Sets the items to paginate.
*
* @param getItems The items to paginate.
* @return The pagination builder.
*/
public PaginationBuilder<T> items(Function<FetchItems, List<T>> getItems) {
this.items = getItems;
return this;
}
/**
* Builds the pagination.
*
* @return The pagination.
*/
public PaginationBuilder<T> build() {
return new PaginationBuilder<>();
}
/**
* Gets a page of items.
*
* @param page The page number.
* @return The page.
*/
public Page<T> getPage(int page) {
List<T> items = this.items.apply(new FetchItems(page, this.itemsPerPage));
int totalPages = (int) Math.ceil((double) this.totalItems / this.itemsPerPage);
if (page < 1 || page > totalPages) {
throw new BadRequestException("Invalid page number");
}
return new Page<>(
items,
new Page.Metadata(page, totalPages, this.totalItems)
);
}
@AllArgsConstructor
@Getter
public static class FetchItems {
/**
* The current page.
*/
private final int currentPage;
/**
* The items per page.
*/
private final int itemsPerPage;
/**
* The amount of items to skip.
*/
public int skipAmount() {
return (currentPage - 1) * itemsPerPage;
}
}
@AllArgsConstructor
@Getter
public static class Page<T> {
/**
* The items on the page.
*/
private final List<T> items;
/**
* The metadata of the page.
*/
private final Metadata metadata;
@AllArgsConstructor
@Getter
public static class Metadata {
/**
* The page number.
*/
private final int page;
/**
* The total number of pages.
*/
private final int totalPages;
/**
* The total number of items.
*/
private final int totalItems;
}
}
}

View File

@ -0,0 +1,93 @@
package cc.fascinated.common;
import kong.unirest.core.Headers;
import kong.unirest.core.HttpResponse;
import kong.unirest.core.Unirest;
import kong.unirest.core.UnirestParsingException;
import lombok.extern.log4j.Log4j2;
import java.util.List;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@Log4j2(topic = "Request")
public class Request {
/**
* The rate limit headers.
*/
private static final List<String> rateLimitHeaders = List.of(
"X-RateLimit-Remaining",
"RateLimit-Remaining"
);
/**
* The rate limit reset headers.
*/
private static final List<String> rateLimitResetHeaders = List.of(
"X-RateLimit-Reset",
"RateLimit-Reset"
);
/**
* Sends a GET request to a URL.
*
* @param url the URL to send the request to
* @param clazz the class to parse the response to
* @param <T> the type of the response
* @return the response
*/
public static <T> HttpResponse<T> get(String url, Class<T> clazz) {
HttpResponse<T> response = Unirest.get(url).asObject(clazz);
int rateLimitRemaining = getRateLimitRemaining(response);
if (rateLimitRemaining == 0) {
long rateLimitReset = getRateLimitReset(response);
long timeLeft = rateLimitReset - System.currentTimeMillis();
try {
Thread.sleep(timeLeft);
} catch (InterruptedException e) {
log.error("Failed to sleep for rate limit reset", e);
}
response = Unirest.get(url).asObject(clazz);
}
response.getParsingError().ifPresent(e -> log.error("Failed to parse response", e));
return response;
}
/**
* Gets the rate limit remaining.
*
* @param response the response to get the rate limit remaining from
* @return the rate limit remaining
*/
public static int getRateLimitRemaining(HttpResponse<?> response) {
Headers headers = response.getHeaders();
for (String rateLimitHeader : rateLimitHeaders) {
if (headers.containsKey(rateLimitHeader)) {
return Integer.parseInt(headers.getFirst(rateLimitHeader));
}
}
return -1;
}
/**
* Gets the rate limit reset absolute time.
*
* @param response the response to get the rate limit reset time from
* @return the rate limit reset time
*/
public static long getRateLimitReset(HttpResponse<?> response) {
Headers headers = response.getHeaders();
for (String rateLimitResetHeader : rateLimitResetHeaders) {
if (headers.containsKey(rateLimitResetHeader)) {
long reset = Long.parseLong(headers.getFirst(rateLimitResetHeader));
if (reset < 86400) {// Assume it's in seconds left
return System.currentTimeMillis() + reset * 1000;
}
return reset * 1000; // Assume it's in seconds
}
}
return -1;
}
}

View File

@ -0,0 +1,33 @@
package cc.fascinated.common;
import java.math.BigInteger;
/**
* @author Fascinated (fascinated7)
*/
public class StringUtils {
/**
* Converts a string to a hexadecimal string.
*
* @param arg the string to convert
* @return the hexadecimal string
*/
public static String toHex(String arg) {
return String.format("%040x", new BigInteger(1, arg.getBytes()));
}
/**
* Generates a random string.
*
* @param length the length of the string
* @return the random string
*/
public static String randomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < length; i++) {
stringBuilder.append(chars.charAt((int) (Math.random() * chars.length())));
}
return stringBuilder.toString();
}
}

View File

@ -0,0 +1,166 @@
package cc.fascinated.common;
import lombok.*;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public final class TimeUtils {
/**
* Format a time in millis to a readable time format.
*
* @param millis the millis to format
* @return the formatted time
*/
public static String format(long millis) {
return format(millis, BatTimeFormat.FIT);
}
/**
* Format a time in millis to a readable time format.
*
* @param millis the millis to format
* @param timeUnit the time unit to format the millis to
* @return the formatted time
*/
public static String format(long millis, BatTimeFormat timeUnit) {
return format(millis, timeUnit, false);
}
/**
* Format a time in millis to a readable time format.
*
* @param millis the millis to format
* @param timeUnit the time unit to format the millis to
* @param compact whether to use a compact display
* @return the formatted time
*/
public static String format(long millis, BatTimeFormat timeUnit, boolean compact) {
return format(millis, timeUnit, true, compact);
}
/**
* Format a time in millis to a readable time format.
*
* @param millis the millis to format
* @param timeUnit the time unit to format the millis to
* @param decimals whether to include decimals
* @param compact whether to use a compact display
* @return the formatted time
*/
public static String format(long millis, BatTimeFormat timeUnit, boolean decimals, boolean compact) {
if (millis == -1L) { // Format permanent
return "Perm" + (compact ? "" : "anent");
}
// Format the time to the best fitting time unit
if (timeUnit == BatTimeFormat.FIT) {
for (BatTimeFormat otherTimeUnit : BatTimeFormat.VALUES) {
if (otherTimeUnit != BatTimeFormat.FIT && millis >= otherTimeUnit.getMillis()) {
timeUnit = otherTimeUnit;
break;
}
}
}
double time = MathUtils.format((double) millis / timeUnit.getMillis(), 1); // Format the time
if (!decimals) { // Remove decimals
time = (int) time;
}
String formatted = time + (compact ? timeUnit.getSuffix() : " " + timeUnit.getDisplay()); // Append the time unit
if (time != 1.0 && !compact) { // Pluralize the time unit
formatted += "s";
}
return formatted;
}
/**
* Convert the given input into a time in millis.
* <p>
* E.g: 1d, 1h, 1d1h, etc
* </p>
*
* @param input the input to parse
* @return the time in millis
*/
public static long fromString(String input) {
Matcher matcher = BatTimeFormat.SUFFIX_PATTERN.matcher(input); // Match the given input
long millis = 0; // The total millis
// Match corresponding suffixes and add up the total millis
while (matcher.find()) {
int amount = Integer.parseInt(matcher.group(1)); // The amount of time to add
String suffix = matcher.group(2); // The unit suffix
BatTimeFormat timeUnit = BatTimeFormat.fromSuffix(suffix); // The time unit to add
if (timeUnit != null) { // Increment the total millis
millis += amount * timeUnit.getMillis();
}
}
return millis;
}
/**
* Represents a unit of time.
*/
@NoArgsConstructor
@AllArgsConstructor
@Getter(AccessLevel.PRIVATE)
@ToString
public enum BatTimeFormat {
FIT,
YEARS("Year", "y", TimeUnit.DAYS.toMillis(365L)),
MONTHS("Month", "mo", TimeUnit.DAYS.toMillis(30L)),
WEEKS("Week", "w", TimeUnit.DAYS.toMillis(7L)),
DAYS("Day", "d", TimeUnit.DAYS.toMillis(1L)),
HOURS("Hour", "h", TimeUnit.HOURS.toMillis(1L)),
MINUTES("Minute", "m", TimeUnit.MINUTES.toMillis(1L)),
SECONDS("Second", "s", TimeUnit.SECONDS.toMillis(1L)),
MILLISECONDS("Millisecond", "ms", 1L);
/**
* Our cached unit values.
*/
public static final BatTimeFormat[] VALUES = values();
/**
* Our cached suffix pattern.
*/
public static final Pattern SUFFIX_PATTERN = Pattern.compile("(\\d+)(mo|ms|[ywdhms])");
/**
* The display of this time unit.
*/
private String display;
/**
* The suffix of this time unit.
*/
private String suffix;
/**
* The amount of millis in this time unit.
*/
private long millis;
/**
* Get the time unit with the given suffix.
*
* @param suffix the time unit suffix
* @return the time unit, null if not found
*/
@Nullable
public static BatTimeFormat fromSuffix(String suffix) {
for (BatTimeFormat unit : VALUES) {
if (unit != FIT && unit.getSuffix().equals(suffix)) {
return unit;
}
}
return null;
}
}
}

View File

@ -0,0 +1,21 @@
package cc.fascinated.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class Tuple<L, R> {
/**
* The left value of the tuple.
*/
private final L left;
/**
* The right value of the tuple.
*/
private final R right;
}

View File

@ -0,0 +1,73 @@
package cc.fascinated.config;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
/**
* @author Braydon
*/
@Configuration
@Log4j2(topic = "Redis")
public class RedisConfig {
/**
* The Redis server host.
*/
@Value("${spring.data.redis.host}")
private String host;
/**
* The Redis server port.
*/
@Value("${spring.data.redis.port}")
private int port;
/**
* The Redis database index.
*/
@Value("${spring.data.redis.database}")
private int database;
/**
* The optional Redis password.
*/
@Value("${spring.data.redis.auth}")
private String auth;
/**
* Build the config to use for Redis.
*
* @return the config
* @see RedisTemplate for config
*/
@Bean @NonNull
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(jedisConnectionFactory());
return template;
}
/**
* Build the connection factory to use
* when making connections to Redis.
*
* @return the built factory
* @see JedisConnectionFactory for factory
*/
@Bean @NonNull
public JedisConnectionFactory jedisConnectionFactory() {
log.info("Connecting to Redis at {}:{}/{}", host, port, database);
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setDatabase(database);
if (!auth.trim().isEmpty()) { // Auth with our provided password
log.info("Using auth...");
config.setPassword(auth);
}
return new JedisConnectionFactory(config);
}
}

View File

@ -0,0 +1,63 @@
package cc.fascinated.controller;
import cc.fascinated.model.auth.LoginRequest;
import cc.fascinated.model.auth.AuthToken;
import cc.fascinated.services.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/auth", produces = MediaType.APPLICATION_JSON_VALUE)
public class AuthenticationController {
/**
* The user service to use
*/
private final UserService userService;
@Autowired
public AuthenticationController(UserService userService) {
this.userService = userService;
}
/**
* A POST request to get an auth token from a steam ticket.
*/
@ResponseBody
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> getAuthToken(@RequestBody LoginRequest request) {
if (request == null || request.getTicket() == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid request or missing ticket"
));
}
AuthToken authToken = this.userService.getAuthToken(request.getTicket());
return ResponseEntity.ok()
.header("Authorization", authToken.getAuthToken())
.build();
}
/**
* A POST request to validate an auth token.
*/
@ResponseBody
@PostMapping(value = "/validate")
public ResponseEntity<?> validateAuthToken(@RequestHeader("Authorization") String authToken) {
String token = authToken == null ? null : authToken.replace("Bearer ", "");
if (token == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid request or missing token"
));
}
return new ResponseEntity<>(this.userService.isValidAuthToken(token) ? HttpStatus.OK : HttpStatus.UNAUTHORIZED);
}
}

View File

@ -0,0 +1,28 @@
package cc.fascinated.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/")
public class RootController {
/**
* A GET mapping to show the welcome message.
*/
@ResponseBody
@GetMapping(value = "/")
public ResponseEntity<?> getWelcome() {
return ResponseEntity.ok(Map.of(
"message", "Hello!",
"url", "https://git.fascinated.cc/Fascinated/beatsaber-scoretracker"
));
}
}

View File

@ -0,0 +1,85 @@
package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/scores")
public class ScoresController {
/**
* The tracked score service to use.
*/
@NonNull
private final ScoreService scoreService;
/**
* The user service to use
*/
@NonNull
private final UserService userService;
@Autowired
public ScoresController(@NonNull ScoreService scoreService, @NonNull UserService userService) {
this.scoreService = scoreService;
this.userService = userService;
}
/**
* A GET mapping to retrieve the top
* scores for a platform
*
* @param platform the platform to get the scores from
* @return the scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/top/{platform}")
public ResponseEntity<?> getTopScores(
@PathVariable String platform,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "false") boolean scoresonly
) {
return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), page, scoresonly));
}
/**
* A GET mapping to retrieve the total
* amount of scores for a platform
*
* @param platform the platform to get the scores from
* @return the amount of scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/count/{platform}")
public ResponseEntity<?> getScoresCount(@PathVariable String platform) {
return ResponseEntity.ok(scoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
}
/**
* A GET mapping to retrieve the score
* history for a leaderboard
*
* @param platform the platform to get the history from
* @return the score history
* @throws BadRequestException if there were no history found
*/
@ResponseBody
@GetMapping(value = "/history/{platform}/{playerId}/{leaderboardId}")
public ResponseEntity<?> getScoreHistory(@PathVariable String platform, @PathVariable String playerId, @PathVariable String leaderboardId) {
return ResponseEntity.ok(scoreService.getScoreHistory(
Platform.Platforms.getPlatform(platform),
userService.getUser(playerId),
leaderboardId
));
}
}

View File

@ -1,7 +1,7 @@
package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.services.UserService;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
@ -17,7 +17,8 @@ public class UserController {
/**
* The user service to use
*/
@NonNull private final UserService userService;
@NonNull
private final UserService userService;
@Autowired
public UserController(@NonNull UserService userService) {
@ -33,7 +34,21 @@ public class UserController {
*/
@ResponseBody
@GetMapping(value = "/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id));
public ResponseEntity<UserDTO> getUser(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id).getAsDTO());
}
/**
* A GET mapping to retrieve a user's statistic
* history using the users steam id.
*
* @param id the id of the user
* @return the user's statistic history
* @throws BadRequestException if the user is not found
*/
@ResponseBody
@GetMapping(value = "/histories/{id}")
public ResponseEntity<?> getUserHistories(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id).getHistory().getPreviousHistories(30));
}
}

View File

@ -0,0 +1,50 @@
package cc.fascinated.log;
import cc.fascinated.common.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* Responsible for logging request and
* response transactions to the terminal.
*
* @author Braydon
* @see HttpServletRequest for request
* @see HttpServletResponse for response
*/
@ControllerAdvice
@Slf4j(topic = "Req/Res Transaction")
public class TransactionLogger implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType,
@NonNull ServerHttpRequest rawRequest, @NonNull ServerHttpResponse rawResponse) {
HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest();
HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse();
// Get the request ip ip
String ip = IPUtils.getRealIp(request);
log.info("[Request] %s - %s %s %s".formatted(
ip, request.getMethod(), request.getRequestURI(), response.getStatus()
));
return body;
}
}

View File

@ -0,0 +1,25 @@
package cc.fascinated.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Setter
public class Counter {
/**
* The ID of the counter.
*/
@Id
private String id;
/**
* The next number in the counter.
*/
private long next;
}

View File

@ -0,0 +1,29 @@
package cc.fascinated.model.auth;
import cc.fascinated.common.StringUtils;
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@RedisHash(value = "AuthToken", timeToLive = 60 * 60 * 6) // 6 hours
public class AuthToken {
/**
* The auth token of the user.
*/
@Id
private final String authToken;
/**
* The id of the user.
*/
private final UUID userId;
}

View File

@ -0,0 +1,14 @@
package cc.fascinated.model.auth;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class LoginRequest {
/**
* The ticket to authenticate the user.
*/
private String ticket;
}

View File

@ -0,0 +1,21 @@
package cc.fascinated.model.leaderboard;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class Difficulty {
/**
* The difficulty of the song.
*/
private String difficulty;
/**
* The raw difficulty of the song.
*/
private String difficultyRaw;
}

View File

@ -0,0 +1,81 @@
package cc.fascinated.model.leaderboard;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class Leaderboard {
/**
* The ID of the leaderboard.
*/
private String id;
/**
* The hash of the song.
*/
private String songHash;
/**
* The name of the song.
*/
private String songName;
/**
* The sub name of the song.
*/
private String songSubName;
/**
* The author of the song.
*/
private String songAuthorName;
/**
* The mapper of the song.
*/
private String levelAuthorName;
/**
* The star rating for this leaderboard.
*/
private double stars;
/**
* The image of the song for this leaderboard.
*/
private String image;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* Constructs a new {@link Leaderboard} object
* from a {@link ScoreSaberLeaderboardToken} object.
*
* @param token The token to construct the object from.
* @return The leaderboard.
*/
public static Leaderboard getFromScoreSaberToken(ScoreSaberLeaderboardToken token) {
return new Leaderboard(
token.getId(),
token.getSongHash(),
token.getSongName(),
token.getSongSubName(),
token.getSongAuthorName(),
token.getLevelAuthorName(),
token.getStars(),
token.getCoverImage(),
new Difficulty(
ScoreSaberUtils.parseDifficulty(token.getDifficulty().getDifficulty()),
token.getDifficulty().getDifficultyRaw()
)
);
}
}

View File

@ -0,0 +1,39 @@
package cc.fascinated.model.score;
import cc.fascinated.model.user.hmd.DeviceController;
import cc.fascinated.model.user.hmd.DeviceHeadset;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class DeviceInformation {
/**
* The headset that was used to set the score.
*/
private final DeviceHeadset headset;
/**
* The left controller that was used to set the score.
*/
private final DeviceController leftController;
/**
* The right controller that was used to set the score.
*/
private final DeviceController rightController;
/**
* Checks if the device information contains unknown values.
*
* @return if the device information contains unknown values
*/
public boolean containsUnknownDevices() {
return headset == DeviceHeadset.UNKNOWN
|| leftController == DeviceController.UNKNOWN
|| rightController == DeviceController.UNKNOWN;
}
}

View File

@ -0,0 +1,161 @@
package cc.fascinated.model.score;
import cc.fascinated.platform.Platform;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Setter
@Document("scores")
public class Score {
/**
* The ID of the score.
* <p>
* This is an internal ID to avoid clashing with other scores.
* This is not the ID of the score on the platform.
* </p>
*/
@Id
@JsonIgnore
private final long id;
/**
* The ID of the player that set the score.
*/
@Indexed
@JsonIgnore
private final String playerId;
/**
* The platform the score was set on.
* <p>
* eg: {@link Platform.Platforms#SCORESABER}
* </p>
*/
@Indexed
@JsonIgnore
private final Platform.Platforms platform;
/**
* The ID of the score of the platform it was set on.
*/
@Indexed
@JsonProperty("scoreId")
private final String platformScoreId;
/**
* The ID of the leaderboard the score was set on.
*/
@Indexed
@JsonIgnore
private final String leaderboardId;
/**
* The rank of the score when it was set.
*/
private int rank;
/**
* The accuracy of the score in a percentage.
*/
private final double accuracy;
/**
* The PP of the score.
* <p>
* e.g. 500pp
* </p>
*/
@Indexed
private Double pp;
/**
* The score of the score.
*/
private final int score;
/**
* The list of modifiers used in the score.
*/
private final String[] modifiers;
/**
* The number of misses in the score.
*/
private final Integer misses;
/**
* The number of bad cuts in the score.
*/
private final Integer badCuts;
/**
* The device information that was used to set the score.
* <p>
* Headset and controllers information.
* </p>
*/
private final DeviceInformation deviceInformation;
/**
* The timestamp of when the score was set.
*/
private final Date timestamp;
/**
* Gets the misses of the score.
*
* @return the misses
*/
public Integer getMisses() {
return misses == null ? 0 : misses;
}
/**
* Gets the bad cuts of the score.
*
* @return the bad cuts
*/
public Integer getBadCuts() {
return badCuts == null ? 0 : badCuts;
}
/**
* Gets the weight of the score.
*
* @return the weight
*/
public Double getPp() {
return pp == null ? 0 : pp;
}
/**
* Gets the modifiers of the score.
*
* @return the modifiers
*/
public String[] getModifiers() {
return modifiers == null ? new String[0] : modifiers;
}
/**
* Gets if the score is ranked.
*
* @return true if the score is ranked, false otherwise
*/
public boolean isRanked() {
return pp != null;
}
}

View File

@ -0,0 +1,60 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScore extends Score {
/**
* The weight of the score.
*/
private final Double weight;
/**
* The multiplier of the score.
*/
private final double multiplier;
/**
* The maximum combo achieved in the score.
*/
private final int maxCombo;
public ScoreSaberScore(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, Double pp, int score, String[] modifiers, Integer misses, Integer badCuts, DeviceInformation deviceInformation,
Date timestamp, Double weight, double multiplier, int maxCombo) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses,
badCuts, deviceInformation, timestamp);
this.weight = weight;
this.multiplier = multiplier;
this.maxCombo = maxCombo;
}
/**
* Gets the modified score.
*
* @return the modified score
*/
public int getModifiedScore() {
if (multiplier == 1) {
return getScore();
}
return (int) (getScore() * multiplier);
}
/**
* Gets the weight of the score.
*
* @return the weight of the score
*/
public Double getWeight() {
return weight == null ? 0 : weight;
}
}

View File

@ -0,0 +1,48 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScoreResponse extends ScoreSaberScore {
/**
* The user that set the score.
*/
private final UserDTO user;
/**
* The leaderboard the score was set on.
*/
private final Leaderboard leaderboard;
public ScoreSaberScoreResponse(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, DeviceInformation deviceInformation,
Date timestamp, double weight, double multiplier, int maxCombo, UserDTO user, Leaderboard leaderboard) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts,
deviceInformation, timestamp, weight, multiplier, maxCombo);
this.user = user;
this.leaderboard = leaderboard;
}
/**
* Creates a new score saber score response.
*
* @param score the score to create the response from
* @param user the user that set the score
* @param leaderboard the leaderboard the score was set on
* @return the score saber score response
*/
public static ScoreSaberScoreResponse fromScore(ScoreSaberScore score, UserDTO user, Leaderboard leaderboard) {
return new ScoreSaberScoreResponse(score.getId(), score.getPlayerId(), score.getPlatform(), score.getPlatformScoreId(), score.getLeaderboardId(),
score.getRank(), score.getAccuracy(), score.getPp(), score.getScore(), score.getModifiers(), score.getMisses(), score.getBadCuts(),
score.getDeviceInformation(), score.getTimestamp(), score.getWeight(), score.getMultiplier(), score.getMaxCombo(), user, leaderboard);
}
}

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;

View File

@ -0,0 +1,19 @@
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Setter
@ToString
public class ScoreSaberLeaderboardPageToken {
/**
* The scores on this page.
*/
private ScoreSaberLeaderboardToken[] leaderboards;
/**
* The metadata for this page.
*/
private ScoreSaberPageMetadataToken metadata;
}

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.Setter;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.AllArgsConstructor;

View File

@ -0,0 +1,55 @@
package cc.fascinated.model.token.steam;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class SteamAuthenticateUserTicketToken {
/**
* The response from the Steam API.
*/
private Response response;
@Getter
public static class Response {
/**
* The params of the response.
*/
private Params params;
@Getter
public static class Params {
/**
* The result of the request.
*/
private String result;
/**
* The steam id of the user.
*/
@JsonProperty("steamid")
private String steamId;
/**
* The owner steam id of the user.
*/
@JsonProperty("ownersteamid")
private String ownerSteamId;
/**
* The vac banned status of the user.
*/
@JsonProperty("vacbanned")
private boolean vacBanned;
/**
* The publisher banned status of the user.
*/
@JsonProperty("publisherbanned")
private boolean publisherBanned;
}
}
}

View File

@ -0,0 +1,63 @@
package cc.fascinated.model.user;
import cc.fascinated.common.DateUtils;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class ScoreSaberAccount {
/**
* The avatar of the user.
*/
private String avatar;
/**
* The country of the user.
*/
private String country;
/**
* The rank of the user.
*/
private int rank;
/**
* The country rank of the user.
*/
private int countryRank;
/**
* The date the user joined ScoreSaber.
*/
private Date accountCreated;
/**
* The date the user was last updated.
*/
private Date lastUpdated;
/**
* Constructs a new {@link ScoreSaberAccount} object
* from a {@link ScoreSaberAccountToken} object.
*
* @param token The token to construct the object from.
* @return The scoresaber account.
*/
public static ScoreSaberAccount getFromToken(ScoreSaberAccountToken token) {
return new ScoreSaberAccount(
token.getProfilePicture(),
token.getCountry(),
token.getRank(),
token.getCountryRank(),
DateUtils.getDateFromIsoString(token.getFirstSeen()),
new Date()
);
}
}

View File

@ -0,0 +1,111 @@
package cc.fascinated.model.user;
import cc.fascinated.model.user.history.History;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.ScoreService;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
@RequiredArgsConstructor
@Getter
@Setter
@Log4j2
@ToString
@Document("user")
public class User {
/**
* The ID of the user.
*/
@Id
@JsonIgnore
private final UUID id;
/**
* The username of the user.
* <p>
* Usually their Steam name.
* </p>
*/
@Indexed
private String username;
/**
* The ID of the users steam profile.
*/
@Indexed
@JsonProperty("id")
private String steamId;
/**
* Whether the user has logged into the website.
* <p>
* This is used to determine if we should track their profiles or not.
* If they haven't logged in, we don't want to track their profiles.
* </p>
*/
@JsonIgnore
public boolean linkedAccount;
/**
* The user's ScoreSaber account.
*/
public ScoreSaberAccount scoresaberAccount;
/**
* The user's statistic history.
*/
public History history;
/**
* Gets the user's statistic history
*/
public History getHistory() {
if (this.history == null) {
this.history = new History();
}
return this.history;
}
/**
* Gets the user's today history.
*
* @return the user's today history
*/
public HistoryPoint getTodayHistory() {
HistoryPoint todayHistory = this.getHistory().getTodayHistory();
if (todayHistory.getTotalPlayCount() == null) {
todayHistory.setTotalPlayCount(ScoreService.INSTANCE.getTotalScores(Platform.Platforms.SCORESABER, this));
}
if (todayHistory.getTotalRankedPlayCount() == null) {
todayHistory.setTotalRankedPlayCount(ScoreService.INSTANCE.getTotalRankedScores(Platform.Platforms.SCORESABER, this));
}
if (todayHistory.getTotalUnrankedPlayCount() == null) {
todayHistory.setTotalUnrankedPlayCount(ScoreService.INSTANCE.getTotalUnrankedScores(Platform.Platforms.SCORESABER, this));
}
return todayHistory;
}
/**
* Gets the user as a DTO.
*
* @return the user as a DTO
*/
@JsonIgnore
public UserDTO getAsDTO() {
return new UserDTO(this.id, this.username, this.steamId, this.scoresaberAccount);
}
}

View File

@ -0,0 +1,36 @@
package cc.fascinated.model.user;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class UserDTO {
/**
* The ID of the user.
*/
private final UUID id;
/**
* The username of the user.
* <p>
* Usually their Steam name.
* </p>
*/
private String username;
/**
* The ID of the users steam profile.
*/
private String steamId;
/**
* The user's ScoreSaber account.
*/
public ScoreSaberAccount scoresaberAccount;
}

View File

@ -0,0 +1,78 @@
package cc.fascinated.model.user.history;
import cc.fascinated.common.DateUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.*;
/**
* @author Fascinated (fascinated7)
*/
public class History {
/**
* The user's history points in time.
*/
private Map<String, HistoryPoint> histories;
/**
* The user's history points history.
*/
@JsonIgnore
public Map<Date, HistoryPoint> getHistories() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Map<Date, HistoryPoint> toReturn = new HashMap<>();
this.histories.forEach((key, value) -> toReturn.put(DateUtils.getDateFromString(key), value));
return toReturn;
}
/**
* Gets the user's history for today.
*
* @return the user's history for today
*/
@JsonIgnore
public HistoryPoint getTodayHistory() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Date midnight = DateUtils.getMidnightToday();
return this.histories.computeIfAbsent(DateUtils.formatDate(midnight), key -> new HistoryPoint());
}
/**
* Gets the user's history for a specific date.
*
* @param date the date to get the history for
* @return the user's history for the date
*/
public HistoryPoint getHistoryForDate(Date date) {
if (this.histories == null) {
this.histories = new HashMap<>();
}
return this.histories.get(DateUtils.formatDate(date));
}
/**
* Gets the user's HistoryPoint history for
* an amount of days ago.
*
* @param days the amount of days ago
* @return the user's HistoryPoint history
*/
public TreeMap<String, HistoryPoint> getPreviousHistories(int days) {
Date date = DateUtils.getDaysAgo(days);
Map<String, HistoryPoint> toReturn = new HashMap<>();
for (Map.Entry<Date, HistoryPoint> history : getHistories().entrySet()) {
if (history.getKey().after(date)) {
toReturn.put(DateUtils.formatDate(history.getKey()), history.getValue());
}
}
// Sort the history by date (newest > oldest)
TreeMap<String, HistoryPoint> sorted = new TreeMap<>(Comparator.comparing(DateUtils::getDateFromString).reversed());
sorted.putAll(toReturn);
return sorted;
}
}

View File

@ -0,0 +1,90 @@
package cc.fascinated.model.user.history;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import lombok.Setter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
@Setter
public class HistoryPoint {
// These are data points that are provided by ScoreSaber, and may not always be here
/**
* The rank of the player.
*/
private Integer rank;
/**
* The pp of the player.
*/
private Integer countryRank;
/**
* The pp of the player.
*/
private Double pp;
// Below are data points that are provided by us, therefore they will always be here
/**
* Play count of all the player's scores.
*/
private Integer totalPlayCount;
/**
* Play count of all the player's ranked scores.
*/
private Integer totalRankedPlayCount;
/**
* Play count of all the player's unranked scores.
*/
private Integer totalUnrankedPlayCount;
/**
* Play count for this day's unranked scores.
*/
private Integer unrankedPlayCount = 0;
/**
* Play count for this day's ranked scores.
*/
private Integer rankedPlayCount = 0;
/**
* Whether the data for this day is possibly inaccurate.
*/
private Boolean possiblyInaccurateData;
/**
* Gets whether some data is possibly inaccurate.
* <p>
* eg: if the user doesn't have their data tracked by
* {@link Platform#trackPlayerMetrics()} then some
* data will be inaccurate or missing.
* This will only affect {@link #rank}, {@link #countryRank}, and {@link #pp}.
* </p>
*
* @return true if the data is possibly inaccurate, false otherwise
*/
public Boolean getPossiblyInaccurateData() {
return possiblyInaccurateData == null || possiblyInaccurateData;
}
/**
* Increment the total ranked play count for this day.
*/
public void incrementRankedPlayCount() {
rankedPlayCount++;
}
/**
* Increment the total unranked play count for this day.
*/
public void incrementUnrankedPlayCount() {
unrankedPlayCount++;
}
}

View File

@ -0,0 +1,50 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceController {
UNKNOWN("Unknown"),
/**
* Oculus Controllers
*/
OCULUS_QUEST_TOUCH("Touch"),
OCULUS_QUEST_2_TOUCH("Quest 2 Touch"),
OCULUS_QUEST_3_TOUCH("Quest 3 Touch"),
/**
* HP Controllers
*/
HP_REVERB("HP Reverb"),
/**
* Valve Controllers
*/
VALVE_KNUCKLES("Knuckles");
/**
* The controller name
*/
private final String name;
/**
* Gets a controller by its name.
*
* @param name the name of the controller
* @return the controller
*/
public static DeviceController getByName(String name) {
for (DeviceController deviceController : values()) {
if (deviceController.getName().equalsIgnoreCase(name)) {
return deviceController;
}
}
return null;
}
}

View File

@ -0,0 +1,84 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceHeadset {
UNKNOWN("Unknown", 0),
/**
* Oculus HMDs
*/
OCULUS_CV1("Rift", 1),
OCULUS_QUEST("Quest", 32),
OCULUS_QUEST_2("Quest 2", -1),
OCULUS_QUEST_3("Quest 3", -1),
OCULUS_RIFT_S("Rift S", 16),
/**
* Windows Mixed Reality HMDs
* todo: find the new format name
*/
WINDOWS_MR("Windows Mixed Reality", 8),
/**
* HTC HMDs
*/
HTC_VIVE("Vive", 2),
HTC_VIVE_COSMOS("Vive Cosmos", 128),
/**
* HP HMDs
*/
HP_REVERB("HP Reverb", -1),
/**
* Valve HMDs
*/
VALVE_INDEX("Valve Index", 64);
/**
* The name of the headset.
*/
private final String name;
/**
* The fallback value of the headset.
*/
private final int fallbackValue;
/**
* Gets a headset by its name.
*
* @param name the name of the headset
* @return the headset
*/
public static DeviceHeadset getByName(String name) {
for (DeviceHeadset deviceHeadset : values()) {
if (deviceHeadset.getName().equalsIgnoreCase(name)) {
return deviceHeadset;
}
}
return null;
}
/**
* Gets a headset by its fallback value.
*
* @param fallbackValue the fallback value of the headset
* @return the headset
*/
public static DeviceHeadset getByFallbackValue(int fallbackValue) {
for (DeviceHeadset deviceHeadset : values()) {
if (deviceHeadset.getFallbackValue() == fallbackValue) {
return deviceHeadset;
}
}
return null;
}
}

View File

@ -0,0 +1,27 @@
package cc.fascinated.platform;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class CurvePoint {
/**
* The acc at the curve point.
* <p>
* Acc is divided by 100 to get the actual value.
* </p>
*/
private final double acc;
/**
* The multiplier of the curve point.
* <p>
* This is the multiplier for the pp calculation.
* </p>
*/
private final double multiplier;
}

View File

@ -49,29 +49,39 @@ public abstract class Platform {
}
}
/**
* Gets the curve points for a curve version.
*
* @param curveVersion the curve version to get the curve points for
* @return the curve points
*/
public CurvePoint[] getCurve(int curveVersion) {
this.checkCurveVersion(curveVersion);
return curvePoints.get(curveVersion);
}
/**
* Gets the PP amount from the star count.
*
* @param stars the amount of stars
* @return the pp amount
*/
public abstract double getPp(int curveVersion, double stars, double accuracy);
public abstract double getPp(double stars, double accuracy);
/**
* Called every 10 minutes to update
* the players data in QuestDB.
* Called to update the players
* data in QuestDB.
*/
public abstract void updatePlayers();
public abstract void trackPlayerMetrics();
/**
* Called every 10 minutes to update
* the metrics for total scores, etc.
* Called to update the metrics
* for total scores, etc.
*/
public abstract void updateMetrics();
/**
* Called every day at midnight to update
* the leaderboards.
* Called to update the leaderboards.
*/
public abstract void updateLeaderboards();

View File

@ -0,0 +1,245 @@
package cc.fascinated.platform.impl;
import cc.fascinated.common.DateUtils;
import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.platform.CurvePoint;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.ScoreSaberService;
import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@Component
@DependsOn("scoreService")
@Log4j2
public class ScoreSaberPlatform extends Platform {
/**
* The base multiplier for stars.
*/
private final double starMultiplier = 42.117208413;
/**
* The ScoreSaber service to use
*/
@NonNull
private final ScoreSaberService scoreSaberService;
/**
* The user service to use
*/
@NonNull
private final UserService userService;
/**
* The score service to use
*/
@NonNull
private final ScoreService scoreService;
@Autowired
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull ScoreService scoreService) {
super(Platforms.SCORESABER, 1, Map.of(
1, new CurvePoint[]{
new CurvePoint(0, 0),
new CurvePoint(0.6, 0.18223233667439062),
new CurvePoint(0.65, 0.5866010012767576),
new CurvePoint(0.7, 0.6125565959114954),
new CurvePoint(0.75, 0.6451808210101443),
new CurvePoint(0.8, 0.6872268862950283),
new CurvePoint(0.825, 0.7150465663454271),
new CurvePoint(0.85, 0.7462290664143185),
new CurvePoint(0.875, 0.7816934560296046),
new CurvePoint(0.9, 0.825756123560842),
new CurvePoint(0.91, 0.8488375988124467),
new CurvePoint(0.92, 0.8728710341448851),
new CurvePoint(0.93, 0.9039994071865736),
new CurvePoint(0.94, 0.9417362980580238),
new CurvePoint(0.95, 1),
new CurvePoint(0.955, 1.0388633331418984),
new CurvePoint(0.96, 1.0871883573850478),
new CurvePoint(0.965, 1.1552120359501035),
new CurvePoint(0.97, 1.2485807759957321),
new CurvePoint(0.9725, 1.3090333065057616),
new CurvePoint(0.975, 1.3807102743105126),
new CurvePoint(0.9775, 1.4664726399289512),
new CurvePoint(0.98, 1.5702410055532239),
new CurvePoint(0.9825, 1.697536248647543),
new CurvePoint(0.985, 1.8563887693647105),
new CurvePoint(0.9875, 2.058947159052738),
new CurvePoint(0.99, 2.324506282149922),
new CurvePoint(0.99125, 2.4902905794106913),
new CurvePoint(0.9925, 2.685667856592722),
new CurvePoint(0.99375, 2.9190155639254955),
new CurvePoint(0.995, 3.2022017597337955),
new CurvePoint(0.99625, 3.5526145337555373),
new CurvePoint(0.9975, 3.996793606763322),
new CurvePoint(0.99825, 4.325027383589547),
new CurvePoint(0.999, 4.715470646416203),
new CurvePoint(0.9995, 5.019543595874787),
new CurvePoint(1, 5.367394282890631),
}
));
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.scoreService = scoreService;
}
/**
* Gets the modifier for the given accuracy.
*
* @param accuracy The accuracy.
* @return The modifier.
*/
public double getModifier(double accuracy) {
accuracy = MathUtils.clamp(accuracy, 0, 100) / 100;
CurvePoint[] curve = this.getCurve(this.getCurrentCurveVersion());
if (accuracy <= 0) {
return 0;
}
if (accuracy >= 1) {
return curve[curve.length - 1].getMultiplier();
}
for (int i = 0; i < curve.length - 1; i++) {
CurvePoint point = curve[i];
CurvePoint nextPoint = curve[i + 1];
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
return MathUtils.lerp(
point.getMultiplier(), nextPoint.getMultiplier(),
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
);
}
}
return 0;
}
@Override
public double getPp(double stars, double accuracy) {
if (accuracy <= 1) { // Convert the accuracy to a percentage
accuracy *= 100;
}
double pp = stars * this.starMultiplier;
return this.getModifier(accuracy) * pp;
}
@Override
public void trackPlayerMetrics() {
Date date = DateUtils.getMidnightToday();
for (User user : this.userService.getUsers(true)) {
HistoryPoint history = user.getHistory().getHistoryForDate(date);
if (user.isLinkedAccount()) { // Check if the user has linked their account
ScoreSaberAccountToken account = this.scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
history.setPp(account.getPp());
history.setRank(account.getRank());
history.setCountryRank(account.getCountryRank());
history.setPossiblyInaccurateData(false);
}
// If the player didn't set any scores today, we need to set these values
if (history.getTotalPlayCount() == null || history.getTotalRankedPlayCount() == null || history.getTotalUnrankedPlayCount() == null) {
history.setTotalPlayCount(this.scoreService.getTotalScores(this.getPlatform(), user)); // Get the total scores for the platform
history.setTotalRankedPlayCount(this.scoreService.getTotalRankedScores(this.getPlatform(), user)); // Get the total ranked scores for the platform
history.setTotalUnrankedPlayCount(this.scoreService.getTotalUnrankedScores(this.getPlatform(), user)); // Get the total unranked scores for the platform
}
this.userService.saveUser(user); // Save the user
}
}
@Override
public void updateMetrics() {
// todo: switch to InfluxDB?
// try (Sender sender = questDBService.getSender()) {
// TotalScoresResponse totalScores = ScoreService.INSTANCE.getTotalScores(this.getPlatform());
// sender.table("metrics")
// .symbol("platform", this.getPlatform().getPlatformName())
// .longColumn("total_scores", totalScores.getTotalScores())
// .longColumn("total_ranked_scores", totalScores.getTotalRankedScores())
// .atNow();
// }
}
@Override
public void updateLeaderboards() {
List<Score> scores = ScoreService.INSTANCE.getRankedScores(this.getPlatform());
Map<String, ScoreSaberLeaderboardToken> leaderboards = new HashMap<>();
for (ScoreSaberLeaderboardPageToken rankedLeaderboard : this.scoreSaberService.getRankedLeaderboards()) {
for (ScoreSaberLeaderboardToken leaderboard : rankedLeaderboard.getLeaderboards()) {
leaderboards.put(leaderboard.getId(), leaderboard);
}
}
// Add any missing leaderboards
for (Score score : scores) {
if (leaderboards.containsKey(score.getLeaderboardId())) {
continue;
}
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(score.getLeaderboardId(), true);
leaderboards.put(leaderboard.getId(), leaderboard);
}
log.info("Updating {} leaderboards for platform '{}'",
leaderboards.size(),
this.getPlatform().getPlatformName()
);
// Update the leaderboards
int finished = 0;
for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) {
String id = leaderboardEntry.getKey();
ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue();
try {
List<Score> toUpdate = scores.stream().filter(score -> {
if (!score.getLeaderboardId().equals(id)) { // Check if the leaderboard ID matches
return false;
}
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
return pp != score.getPp(); // Check if the pp has changed
}).toList();
for (Score score : toUpdate) { // Update the scores
if (leaderboard.getStars() == 0) { // The leaderboard was unranked
score.setPp(0D);
}
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
score.setPp(pp);
}
if (!toUpdate.isEmpty()) { // Save the scores
ScoreService.INSTANCE.updateScores(toUpdate.toArray(new Score[0]));
}
finished++;
if (finished % 100 == 0 || finished == leaderboards.size()) {
log.info("Updated {}/{} leaderboards for platform '{}'",
finished,
leaderboards.size(),
this.getPlatform().getPlatformName()
);
}
} catch (Exception ex) {
log.error("An error occurred while updating leaderboard '{}'", id, ex);
}
}
}
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.Counter;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface CounterRepository extends MongoRepository<Counter, String> {}

View File

@ -0,0 +1,161 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.NonNull;
import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.repository.query.Param;
import java.util.Date;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface ScoreRepository extends MongoRepository<Score, Long> {
/**
* Gets the top ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }",
"{ $skip: ?2 }",
"{ $limit: ?1 }",
})
List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, int skip);
/**
* Gets all the ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getRankedScores(@NonNull Platform.Platforms platform);
/**
* Gets the improved scores from the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @param after the date to get the scores after
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, timestamp: { $gt: ?2 } } }",
"{ $match: { 'previousScores.0': { $exists: true } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getUserImprovedScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull Date after);
/**
* Gets the best improved scores from the platform.
*
* @param platform the platform to get the scores from
* @param after the date to get the scores after
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, timestamp: { $gt: ?1 } } }",
"{ $match: { 'previousScores.0': { $exists: true } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getBestImprovedScores(@NonNull Platform.Platforms platform, @NonNull Date after);
/**
* Gets a score from a platform and leaderboard id.
*
* @param platform the platform to get the score from
* @param playerId the player id to get the score from
* @param leaderboardId the leaderboard id to get the score from
* @return the score
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, leaderboardId: ?2 } }",
"{ $sort: { timestamp: -1 } }",
})
List<Score> findScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull String leaderboardId);
/**
* Updates a scores pp value.
*
* @param id The id of the score to update
* @param pp The new pp value
*/
@Aggregation(pipeline = {
"{ $match: { _id: ?0 } }",
"{ $set: { pp: ?1 } }"
})
void updateScorePP(@Param("id") long id, @Param("pp") double pp);
/**
* Gets the total scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0 } }",
"{ $count: 'total' }"
})
Integer getTotalScores(@NonNull Platform.Platforms platform);
/**
* Gets the total ranked scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total ranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $count: 'total' }"
})
Integer getTotalRankedScores(@NonNull Platform.Platforms platform);
/**
* Gets the total player scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1 } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
/**
* Gets the total player ranked scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player ranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, pp: { $gt: 0 } } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerRankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
/**
* Gets the total player unranked scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player unranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, pp: { $eq: null } } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerUnrankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
}

View File

@ -1,6 +1,6 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import org.springframework.data.mongodb.repository.MongoRepository;
/**

View File

@ -0,0 +1,39 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.user.User;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
public interface UserRepository extends MongoRepository<User, UUID> {
/**
* Finds a user by their steam id.
*
* @param steamId the steam id of the user
* @return the user
*/
Optional<User> findBySteamId(String steamId);
/**
* Fetches all users and only their steam ids.
*
* @return the list of users
*/
@Query(value = "{}", fields = "{ 'steamId' : 1, linkedAccount: 1 }")
List<User> fetchAccountsSimple();
/**
* Finds a user by their username.
*
* @param username the username of the user
* @return the user
*/
@Query("{ $text: { $search: ?0 } }")
List<User> findUsersByUsername(String username);
}

View File

@ -0,0 +1,11 @@
package cc.fascinated.repository.redis;
import cc.fascinated.model.auth.AuthToken;
import org.springframework.data.repository.CrudRepository;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}

View File

@ -0,0 +1,54 @@
package cc.fascinated.services;
import cc.fascinated.model.Counter;
import cc.fascinated.repository.mongo.CounterRepository;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class CounterService {
/**
* The counter repository to use.
*/
private final CounterRepository counterRepository;
@Autowired
public CounterService(CounterRepository counterRepository) {
this.counterRepository = counterRepository;
}
/**
* Gets the next number in the sequence.
*
* @param type The type of the counter.
* @return The next number in the sequence.
*/
public long getNext(CounterType type) {
Optional<Counter> counterOptional = counterRepository.findById(type.getId());
Counter counter = counterOptional.orElseGet(() -> new Counter(type.getId(), 1));
long current = counter.getNext();
counter.setNext(current + 1);
counterRepository.save(counter);
return current;
}
@AllArgsConstructor
@Getter
public enum CounterType {
SCORE("score");
/**
* The ID of the counter.
*/
private final String id;
}
}

View File

@ -2,13 +2,13 @@ package cc.fascinated.services;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import com.mongodb.client.model.Filters;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.DependsOn;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@ -21,8 +21,10 @@ import java.util.concurrent.Executors;
* @author Fascinated (fascinated7)
*/
@Service
@DependsOn("mongoService")
@Log4j2(topic = "PlatformService")
public class PlatformService {
public static PlatformService INSTANCE;
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
/**
@ -30,16 +32,9 @@ public class PlatformService {
*/
private final List<Platform> platforms = new ArrayList<>();
/**
* The tracked score repository to use.
*/
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public PlatformService(@NonNull ApplicationContext context, @NonNull TrackedScoreRepository trackedScoreRepository) {
this.trackedScoreRepository = trackedScoreRepository;
public PlatformService(@NonNull ApplicationContext context) {
INSTANCE = this;
log.info("Registering platforms...");
registerPlatform(context.getBean(ScoreSaberPlatform.class));
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
@ -48,7 +43,7 @@ public class PlatformService {
/**
* Updates the platform metrics.
* <p>
* This method is scheduled to run every minute.
* This method is scheduled to run every 5 minutes.
* </p>
*/
@Scheduled(cron = "0 */5 * * * *")
@ -63,16 +58,16 @@ public class PlatformService {
/**
* Updates the platform players.
* <p>
* This method is scheduled to run every 15 minutes.
* This method is scheduled to run every hour.
* </p>
*/
@Scheduled(cron = "0 */15 * * * *")
public void updateScores() {
log.info("Updating %s platform players...".formatted(this.platforms.size()));
@Scheduled(cron = "0 0 * * * *")
public void updatePlayerMetrics() {
log.info("Updating %s platform player metrics...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
platform.trackPlayerMetrics();
}
log.info("Finished updating platform players.");
log.info("Finished updating platform player metrics.");
}
/**
@ -120,12 +115,39 @@ public class PlatformService {
));
log.info("Updating scores for platform '%s'...".formatted(platform.getPlatform().getPlatformName()));
EXECUTOR_SERVICE.execute(platform::updateLeaderboards); // Update the leaderboards
Document finalDocument = document;
EXECUTOR_SERVICE.execute(() -> {
platform.updateLeaderboards();
this.savePlatform(platform, finalDocument);
}); // Update the leaderboards
} else {
this.savePlatform(platform, document);
}
}
// Update the document
/**
* Saves the platform.
*
* @param platform the platform to save
* @param document the document to save
*/
public void savePlatform(Platform platform, Document document) {
document.put("currentCurveVersion", platform.getCurrentCurveVersion());
MongoService.INSTANCE.getPlatformsCollection().replaceOne(Filters.eq("_id", platform.getPlatform().getPlatformName()), document);
}
/**
* Gets the ScoreSaber platform.
*
* @return the ScoreSaber platform
*/
public ScoreSaberPlatform getScoreSaberPlatform() {
for (Platform platform : this.platforms) {
if (platform.getPlatform().getPlatformName().equalsIgnoreCase(Platform.Platforms.SCORESABER.getPlatformName())) {
return (ScoreSaberPlatform) platform;
}
}
return null;
}
}

View File

@ -0,0 +1,157 @@
package cc.fascinated.services;
import cc.fascinated.common.Request;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "ScoreSaber Service")
public class ScoreSaberService {
private static final String SCORESABER_API = "https://scoresaber.com/api/";
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full";
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
private static final String GET_RANKED_LEADERBOARDS_ENDPOINT = SCORESABER_API + "leaderboards?ranked=true&page=%s&withMetadata=true";
private final Map<String, ScoreSaberLeaderboardToken> leaderboardCache = ExpiringMap.builder()
.maxSize(5_000)
.expirationPolicy(ExpirationPolicy.ACCESSED)
.expiration(1, TimeUnit.DAYS)
.build();
/**
* The ScoreSaber leaderboard repository to use.
*/
@NonNull
private final ScoreSaberLeaderboardRepository leaderboardRepository;
@Autowired
public ScoreSaberService(@NonNull ScoreSaberLeaderboardRepository leaderboardRepository) {
this.leaderboardRepository = leaderboardRepository;
}
/**
* Gets the account for a user.
*
* @param user the user to get the account for
* @return the ScoreSaber account
* @throws BadRequestException if an error occurred while getting the account
*/
public ScoreSaberAccountToken getAccount(User user) {
if (user.getSteamId() == null) {
throw new BadRequestException("The user does not have a steam id");
}
HttpResponse<ScoreSaberAccountToken> response = Request.get(GET_PLAYER_ENDPOINT.formatted(user.getSteamId()), ScoreSaberAccountToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber account for '%s'".formatted(user.getSteamId()));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber account for '%s'".formatted(user.getSteamId()));
}
return response.getBody();
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) {
if (leaderboardCache.containsKey(leaderboardId) && !bypassCache) { // The leaderboard is cached locally (very fast)
return leaderboardCache.get(leaderboardId);
}
Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId);
if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached in the database
ScoreSaberLeaderboardToken leaderboard = leaderboardOptional.get();
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard;
}
HttpResponse<ScoreSaberLeaderboardToken> response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard);
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard;
}
/**
* Gets a list of all the ranked leaderboards.
*
* @return the ranked leaderboards
* @throws BadRequestException if an error occurred while getting the leaderboards
*/
public List<ScoreSaberLeaderboardPageToken> getRankedLeaderboards() {
log.info("Getting all ranked leaderboards...");
List<ScoreSaberLeaderboardPageToken> pages = new LinkedList<>();
int page = 1;
do {
log.info("Getting ranked leaderboard page '%s'...".formatted(page));
ScoreSaberLeaderboardPageToken pageToken = getRankedLeaderboards(page);
pages.add(pageToken);
for (ScoreSaberLeaderboardToken leaderboard : pageToken.getLeaderboards()) {
this.leaderboardRepository.save(leaderboard);
}
page++;
} while (page <= ((pages.get(0).getMetadata().getTotal() / pages.get(0).getMetadata().getItemsPerPage()) + 1));
log.info("Finished getting all ranked leaderboards, found '{}' pages.", pages.size());
return pages;
}
/**
* Gets a list of the ranked leaderboards for a page.
*
* @param page the page to get the leaderboards for
* @return the ranked leaderboards
* @throws BadRequestException if an error occurred while getting the leaderboards
*/
public ScoreSaberLeaderboardPageToken getRankedLeaderboards(int page) {
HttpResponse<ScoreSaberLeaderboardPageToken> response = Request.get(GET_RANKED_LEADERBOARDS_ENDPOINT.formatted(page), ScoreSaberLeaderboardPageToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber leaderboard page for page '%s'".formatted(page));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber leaderboard page for page '%s'".formatted(page));
}
return response.getBody();
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId) {
return getLeaderboard(leaderboardId, false);
}
}

View File

@ -0,0 +1,288 @@
package cc.fascinated.services;
import cc.fascinated.common.DateUtils;
import cc.fascinated.common.EnumUtils;
import cc.fascinated.common.MathUtils;
import cc.fascinated.common.PaginationBuilder;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScoreResponse;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.model.user.hmd.DeviceController;
import cc.fascinated.model.user.hmd.DeviceHeadset;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.mongo.ScoreRepository;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "Score Service")
public class ScoreService {
public static ScoreService INSTANCE;
/**
* The counter service to use.
*/
@NonNull
private final CounterService counterService;
/**
* The user service to use.
*/
@NonNull
private final UserService userService;
/**
* The ScoreSaber service to use.
*/
@NonNull
private final ScoreSaberService scoreSaberService;
/**
* The score repository to use.
*/
@NonNull
private final ScoreRepository scoreRepository;
@Autowired
public ScoreService(@NonNull CounterService counterService, @NonNull UserService userService, @NonNull ScoreSaberService scoreSaberService,
@NonNull ScoreRepository scoreRepository) {
INSTANCE = this;
this.counterService = counterService;
this.userService = userService;
this.scoreSaberService = scoreSaberService;
this.scoreRepository = scoreRepository;
}
/**
* Gets the top ranked scores from the platform.
*
* @param platform The platform to get the scores from.
* @param scoresOnly Whether to only get the scores.
* @return The scores.
*/
public PaginationBuilder.Page<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int pageNumber, boolean scoresOnly) {
PaginationBuilder<ScoreSaberScoreResponse> builder = new PaginationBuilder<ScoreSaberScoreResponse>().build();
builder.itemsPerPage(15);
builder.totalItems(() -> this.scoreRepository.getTotalRankedScores(platform));
builder.items((fetchItems) -> {
List<Score> foundScores = this.scoreRepository.getTopRankedScores(platform, fetchItems.getItemsPerPage(), fetchItems.skipAmount());
List<ScoreSaberScoreResponse> scores = new ArrayList<>();
for (Score score : foundScores) {
ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score;
UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO();
Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId()));
scores.add(ScoreSaberScoreResponse.fromScore(scoreSaberScore, user, leaderboard));
}
return scores;
});
return builder.getPage(pageNumber);
}
/**
* Gets all the ranked scores from the platform.
*
* @param platform The platform to get the scores from.
* @return The scores.
*/
public List<Score> getRankedScores(@NonNull Platform.Platforms platform) {
return scoreRepository.getRankedScores(platform);
}
/**
* Updates a scores pp value.
*
* @param scores The scores to update.
*/
public void updateScores(Score... scores) {
for (Score score : scores) {
scoreRepository.updateScorePP(score.getId(), score.getPp());
}
}
/**
* Gets the total scores for the platform.
*
* @param platform The platform to get the scores from.
* @return The total scores.
*/
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
scoreRepository.getTotalScores(platform),
scoreRepository.getTotalRankedScores(platform)
);
}
/**
* Gets the total scores for the platform and user.
*
* @param platform The platform to get the scores from.
* @param user The user to get the scores from.
* @return The total scores.
*/
public int getTotalScores(@NonNull Platform.Platforms platform, @NonNull User user) {
Integer totalPlayerScores = scoreRepository.getTotalPlayerScores(platform, user.getSteamId());
return totalPlayerScores == null ? 0 : totalPlayerScores;
}
/**
* Gets the total ranked scores for the platform and user.
*
* @param platform The platform to get the scores from.
* @param user The user to get the scores from.
* @return The total ranked scores.
*/
public int getTotalRankedScores(@NonNull Platform.Platforms platform, @NonNull User user) {
Integer totalPlayerRankedScores = scoreRepository.getTotalPlayerRankedScores(platform, user.getSteamId());
return totalPlayerRankedScores == null ? 0 : totalPlayerRankedScores;
}
/**
* Gets the total unranked scores for the platform and user.
*
* @param platform The platform to get the scores from.
* @param user The user to get the scores from.
* @return The total unranked scores.
*/
public int getTotalUnrankedScores(@NonNull Platform.Platforms platform, @NonNull User user) {
Integer totalPlayerUnrankedScores = scoreRepository.getTotalPlayerUnrankedScores(platform, user.getSteamId());
return totalPlayerUnrankedScores == null ? 0 : totalPlayerUnrankedScores;
}
/**
* Tracks a ScoreSaber score.
*
* @param token The token of the score to track.
*/
public void trackScoreSaberScore(ScoreSaberPlayerScoreToken token) {
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(token.getLeaderboard().getId());
ScoreSaberScoreToken score = token.getScore();
User user = userService.getUser(score.getLeaderboardPlayerInfo().getId());
double accuracy = leaderboard.getMaxScore() != 0 ? ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100 : 0;
if (accuracy == 0) {
log.warn("[Scoresaber] Leaderboard '{}' has a max score of 0, unable to calculate accuracy :(", leaderboard.getId());
}
double pp = score.getPp() != 0 ? PlatformService.INSTANCE.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy) : 0; // Recalculate the pp
String[] modifiers = !score.getModifiers().isEmpty() ? score.getModifiers().split(",") : new String[0];
DeviceHeadset deviceHmd;
boolean useLegacyHmdIdentification = score.getDeviceHmd() == null && score.getHmd() != 0;
if (!useLegacyHmdIdentification) { // Use the new format
deviceHmd = DeviceHeadset.getByName(score.getDeviceHmd());
} else { // Use the legacy format (only includes the HMD, missing controller information)
deviceHmd = DeviceHeadset.getByFallbackValue(score.getHmd());
}
ScoreSaberScore scoreSaberScore = new ScoreSaberScore(
this.counterService.getNext(CounterService.CounterType.SCORE),
user.getSteamId(),
Platform.Platforms.SCORESABER,
score.getId(),
leaderboard.getId(),
score.getRank(),
accuracy,
pp == 0 ? null : pp, // no pp, set to null to save data
score.getBaseScore(),
modifiers.length == 0 ? null : modifiers, // no modifiers, set to null to save data
score.getMissedNotes() == 0 ? null : score.getMissedNotes(), // no misses, set to null to save data
score.getBadCuts() == 0 ? null : score.getBadCuts(), // no bad cuts, set to null to save data
new DeviceInformation(
deviceHmd,
score.getDeviceControllerLeft() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerLeft()),
score.getDeviceControllerRight() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerRight())
),
DateUtils.getDateFromIsoString(score.getTimeSet()),
score.getWeight() == 0 ? null : score.getWeight(), // no weight, set to null to save data
score.getMultiplier(),
score.getMaxCombo()
);
this.saveScore(user, scoreSaberScore);
this.logScore(Platform.Platforms.SCORESABER, Leaderboard.getFromScoreSaberToken(leaderboard), scoreSaberScore, user);
if (useLegacyHmdIdentification) {
log.info(" - Using legacy device information, headset found: {} (missing controller information)", deviceHmd);
}
}
/**
* Gets the previous scores for a leaderboard.
*
* @param platform The platform to get the score from.
* @param user The user to get the score from.
* @param leaderboardId The leaderboard id to get the score from.
* @return The previous score.
*/
public @NonNull List<Score> getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User
user, @NonNull String leaderboardId) {
List<Score> foundScores = new ArrayList<>(this.scoreRepository.findScores(platform, user.getSteamId(), leaderboardId));
// Sort previous scores by timestamp (newest -> oldest)
foundScores.sort(Comparator.comparing(Score::getTimestamp).reversed());
return foundScores;
}
/**
* Saves a score.
*
* @param score The score to save.
*/
public void saveScore(User user, Score score) {
HistoryPoint todayHistory = user.getTodayHistory();
if (score.isRanked()) {
todayHistory.incrementRankedPlayCount();
} else {
todayHistory.incrementUnrankedPlayCount();
}
Platform.Platforms platform = score.getPlatform();
todayHistory.setTotalPlayCount(this.getTotalScores(platform, user)); // Get the total scores for the platform
todayHistory.setTotalRankedPlayCount(this.getTotalRankedScores(platform, user)); // Get the total ranked scores for the platform
todayHistory.setTotalUnrankedPlayCount(this.getTotalUnrankedScores(platform, user)); // Get the total unranked scores for the platform
userService.saveUser(user); // Save the user
scoreRepository.save(score); // Save the score
}
/**
* Logs a score.
*
* @param platform The platform the score was tracked on.
* @param score The score to log.
* @param user The user who set the score.
*/
private void logScore(@NonNull Platform.Platforms platform, @NonNull Leaderboard leaderboard, @NonNull Score
score,
@NonNull User user) {
String platformName = EnumUtils.getEnumName(platform);
boolean isRanked = score.getPp() != 0;
log.info("[{}] Tracked{} Score! id: {}, acc: {}%, {} score id: {},{} leaderboard: {}, difficulty: {}, player: {} ({})",
platformName,
isRanked ? " Ranked" : "",
score.getId(),
MathUtils.format(score.getAccuracy(), 2),
platformName.toLowerCase(), score.getPlatformScoreId(),
isRanked ? " pp: %s,".formatted(MathUtils.format(score.getPp(), 2)) : "",
score.getLeaderboardId(),
leaderboard.getDifficulty().getDifficulty(),
user.getUsername() == null ? user.getSteamId() : user.getUsername(),
user.getSteamId()
);
}
}

View File

@ -0,0 +1,51 @@
package cc.fascinated.services;
import cc.fascinated.common.Request;
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
import kong.unirest.core.HttpResponse;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2
public class SteamService {
/**
* Steam API endpoints.
*/
private static final String STEAM_API_URL = "https://api.steampowered.com/";
private static final String USER_AUTH_TICKET = STEAM_API_URL + "ISteamUserAuth/AuthenticateUserTicket/v1/?key=%s&appid=620980&ticket=%s";
/**
* The key to use for authentication
* with the Steam API.
*/
private final String steamKey;
@Autowired
public SteamService(@Value("${scoretracker.steam.api-key}") String steamKey) {
this.steamKey = steamKey;
}
/**
* Gets the steam ID from a user's ticket.
*
* @param ticket the ticket to get the steam ID from
* @return the steam ID from the ticket
*/
public SteamAuthenticateUserTicketToken getSteamUserFromTicket(String ticket) {
HttpResponse<SteamAuthenticateUserTicketToken> response = Request.get(
USER_AUTH_TICKET.formatted(steamKey, ticket),
SteamAuthenticateUserTicketToken.class
);
if (response.getStatus() != 200) {
log.error("Failed to get steam ID from ticket: %s".formatted(response.getStatus()));
return null;
}
return response.getBody();
}
}

View File

@ -0,0 +1,192 @@
package cc.fascinated.services;
import cc.fascinated.common.StringUtils;
import cc.fascinated.common.TimeUtils;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.auth.AuthToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
import cc.fascinated.model.user.ScoreSaberAccount;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.repository.mongo.UserRepository;
import cc.fascinated.repository.redis.AuthTokenRepository;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "User Service")
public class UserService {
/**
* The interval to refresh the user's account data from external services
*/
private static long ACCOUNT_REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(1);
/**
* The user repository to use
*/
@NonNull
private final UserRepository userRepository;
/**
* The auth token repository to use
*/
@NonNull
private final AuthTokenRepository authTokenRepository;
/**
* The ScoreSaber service to use
*/
@NonNull
private final ScoreSaberService scoreSaberService;
/**
* The Steam service to use
*/
@NonNull
private final SteamService steamService;
/**
* The user cache to use
*/
private final Map<String, User> userCache = ExpiringMap.builder()
.maxSize(5_000)
.expirationPolicy(ExpirationPolicy.ACCESSED)
.expiration(1, TimeUnit.DAYS)
.build();
@Autowired
public UserService(@NonNull UserRepository userRepository, @NonNull AuthTokenRepository authTokenRepository,
@NonNull ScoreSaberService scoreSaberService, @NonNull SteamService steamService) {
this.userRepository = userRepository;
this.authTokenRepository = authTokenRepository;
this.scoreSaberService = scoreSaberService;
this.steamService = steamService;
}
/**
* Gets a user by their id
*
* @param steamId the id of the user's steam profile
* @return the user
* @throws BadRequestException if the user is not found
*/
public User getUser(String steamId) {
if (!this.isValidSteamId(steamId)) {
throw new BadRequestException("Invalid steam id");
}
if (this.userCache.containsKey(steamId)) {
return this.userCache.get(steamId);
}
Optional<User> userOptional = this.userRepository.findBySteamId(steamId);
User user;
boolean shouldUpdate = false;
if (userOptional.isEmpty()) {
// todo: check the steam API to see if the user exists
user = new User(UUID.randomUUID());
user.setSteamId(steamId);
shouldUpdate = true;
} else {
user = userOptional.get();
}
// Ensure the users ScoreSaber account is up-to-date
ScoreSaberAccount scoresaberAccount = user.getScoresaberAccount();
if (scoresaberAccount == null || scoresaberAccount.getLastUpdated().before(new Date(System.currentTimeMillis() - ACCOUNT_REFRESH_INTERVAL))) {
try {
log.info("[Scoresaber] Updating account for '{}', last update: {}",
steamId,
scoresaberAccount == null ? "now" : TimeUtils.format(System.currentTimeMillis() - scoresaberAccount.getLastUpdated().getTime())
);
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(user);
user.setScoresaberAccount(ScoreSaberAccount.getFromToken(accountToken)); // Update the ScoreSaber account
user.setUsername(accountToken.getName()); // Update the username
HistoryPoint historyToday = user.getTodayHistory();
historyToday.setRank(accountToken.getRank());
historyToday.setCountryRank(accountToken.getCountryRank());
historyToday.setPp(accountToken.getPp());
} catch (Exception ex) {
log.error("[Scoresaber] Failed to update account for '{}'", steamId, ex);
}
shouldUpdate = true;
}
if (shouldUpdate) {
this.saveUser(user); // Save the user
}
this.userCache.put(steamId, user);
return user;
}
/**
* Creates a new auth token using a steam ticket
*
* @param ticket the ticket to get the auth token from
* @return the auth token
* @throws BadRequestException if the ticket is invalid
*/
public AuthToken getAuthToken(String ticket) {
SteamAuthenticateUserTicketToken steamUser = this.steamService.getSteamUserFromTicket(ticket);
assert steamUser != null;
User user = this.getUser(steamUser.getResponse().getParams().getSteamId());
if (user == null) {
throw new BadRequestException("Failed to get user from steam id");
}
return this.authTokenRepository.save(new AuthToken(
StringUtils.randomString(32),
user.getId()
));
}
/**
* Validates an auth token
*
* @param authToken the auth token to validate
* @return true if the auth token is valid, false otherwise
*/
public boolean isValidAuthToken(String authToken) {
return this.authTokenRepository.existsById(authToken);
}
/**
* Saves a user to the database
*
* @param user the user to save
*/
public void saveUser(User user) {
this.userRepository.save(user);
}
/**
* Gets all users in the database
*
* @return all users
*/
public List<User> getUsers(boolean smallerAccount) {
if (smallerAccount) {
return this.userRepository.fetchAccountsSimple();
}
return this.userRepository.findAll();
}
/**
* Validates a steam id
*
* @param steamId the steam id to validate
* @return if the steam id is valid
*/
public boolean isValidSteamId(String steamId) {
return steamId != null && steamId.length() == 17;
}
}

View File

@ -5,6 +5,7 @@ import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
@ -14,7 +15,7 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
*/
@Log4j2
@Getter
public class Websocket extends TextWebSocketHandler {
public abstract class Websocket extends TextWebSocketHandler {
/**
* The name of the WebSocket.
*/
@ -25,19 +26,36 @@ public class Websocket extends TextWebSocketHandler {
*/
private final String url;
/**
* The WebSocket session.
*/
private WebSocketSession webSocketSession;
public Websocket(@NonNull String name, @NonNull String url) {
this.name = name;
this.url = url;
connectWebSocket(); // Connect to the WebSocket.
}
/**
* Handles a message received from the WebSocket.
*
* @param message the message received
*/
public abstract void handleMessage(@NonNull TextMessage message);
@Override
protected final void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
this.handleMessage(message);
}
/**
* Connects to the ScoreSaber WebSocket.
*/
@SneakyThrows
private void connectWebSocket() {
log.info("Connecting to the {}", this.getName());
new StandardWebSocketClient().execute(this, this.getUrl()).get();
this.webSocketSession = new StandardWebSocketClient().execute(this, this.getUrl()).get();
}
@Override

View File

@ -0,0 +1,70 @@
package cc.fascinated.websocket.impl;
import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberWebsocketDataToken;
import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService;
import cc.fascinated.websocket.Websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
/**
* @author Fascinated (fascinated7)
*/
@Component
@Log4j2(topic = "ScoreSaber Websocket")
public class ScoreSaberWebsocket extends Websocket {
/**
* The Jackson deserializer to use.
*/
private final ObjectMapper objectMapper;
/**
* The user service to use
*/
private final UserService userService;
/**
* The score service to use
*/
private final ScoreService scoreService;
@Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull ScoreService scoreService) {
super("ScoreSaber", "wss://scoresaber.com/ws");
this.objectMapper = objectMapper;
this.userService = userService;
this.scoreService = scoreService;
}
@Override
@SneakyThrows
public void handleMessage(@NonNull TextMessage message) {
String payload = message.getPayload();
if (payload.equals("Connected to the ScoreSaber WSS")) { // Ignore the connection message
return;
}
ScoreSaberWebsocketDataToken response = this.objectMapper.readValue(payload, ScoreSaberWebsocketDataToken.class);
if (!response.getCommandName().equals("score")) { // Ignore non-score messages
return;
}
// Decode the message using Jackson
ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken score = scoreToken.getScore();
ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo();
// Ensure the player is valid
if (!this.userService.isValidSteamId(player.getId())) {
return;
}
scoreService.trackScoreSaberScore(scoreToken);
}
}

View File

@ -3,36 +3,31 @@ server:
address: 0.0.0.0
port: 7500
# ScoreTracker Configuration
scoretracker:
steam:
api-key: "xxx"
# Spring Configuration
spring:
data:
# Redis Configuration
redis:
host: localhost
port: 6379
database: 0
auth: ""
# MongoDB Configuration
mongodb:
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
database: "bs-tracker"
auto-index-creation: true # Automatically create collection indexes
datasource:
url: jdbc:postgresql://localhost:5432/<YOUR_DATABASE_NAME>
username: <YOUR_USERNAME>
password: <YOUR_PASSWORD>
jpa:
hibernate:
ddl-auto: <create | create-drop | update | validate | none>
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
# Don't serialize null values by default with Jackson
jackson:
default-property-inclusion: non_null
# QuestDB Configuration
questdb:
host: localhost:9000
username: admin
password: quest
# DO NOT TOUCH BELOW
management:
# Disable all actuator endpoints

3
Frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
Frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

42
Frontend/Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM imbios/bun-node AS base
# Install dependencies
FROM base AS depends
WORKDIR /usr/src/app
COPY package.json* bun.lockb* ./
RUN bun install --frozen-lockfile --quiet
# Build the app
FROM base AS builder
WORKDIR /usr/src/app
COPY --from=depends /usr/src/app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN bun run build
# Run the app
FROM base AS runner
WORKDIR /usr/src/app
RUN addgroup --system --gid 1007 nextjs
RUN adduser --system --uid 1007 nextjs
RUN mkdir .next
RUN chown nextjs:nextjs .next
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/.next ./.next
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/next.config.mjs ./next.config.mjs
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/package.json ./package.json
ENV NODE_ENV production
# Exposting on port 80 so we can
# access via a reverse proxy for Dokku
ENV HOSTNAME "0.0.0.0"
EXPOSE 80
ENV PORT 80
USER nextjs
CMD ["bun", "start"]

1
Frontend/README.md Normal file
View File

@ -0,0 +1 @@
# Frontend

BIN
Frontend/bun.lockb Normal file

Binary file not shown.

4
Frontend/next.config.mjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

26
Frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.2.5"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^22.0.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.5"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Score Tracker",
description: "Tracking your BeatSaber progress, currently tracks ScoreSaber :)",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

17
Frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,17 @@
import Link from "next/link";
export default function Home() {
return (
<main className="flex flex-col items-center w-screen h-screen">
<h1>BeatSaber Metrics</h1>
<p>this website is currently under construction</p>
<Link
className="text-blue-500 hover:opacity-80 transform-gpu transition-all"
href={"https://beatsaber.fascinated.cc/api/"}
>
Visit the API!
</Link>
</main>
);
}

View File

@ -0,0 +1,12 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {},
plugins: [],
};
export default config;

26
Frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Liam (Fascinated).
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

359
Mod/.gitignore vendored Normal file
View File

@ -0,0 +1,359 @@
## Ignore References folder
References/
*.bat
PrivateKeys.cs
MockUp
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*[.json, .xml, .info]
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/

145
Mod/API/Authentication.cs Normal file
View File

@ -0,0 +1,145 @@
using ScoreTracker.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ScoreTracker.API
{
internal class SigninResponse
{
public bool Success { get; set; }
public string Response { get; set; }
}
internal class Authentication
{
private static bool _signedIn = false;
private static string _authToken;
/// <summary>
/// Validate the auth token and sign in if necessary
/// </summary>
public static async Task<SigninResponse> ValidateAndSignIn()
{
if (_signedIn && await ValidateAuthToken())
{
return new SigninResponse
{
Success = true,
Response = null
}; // Already signed in
}
bool success = false;
string response = null;
await LoginUser(
token => {
success = true;
Request.PersistHeaders(new Dictionary<string, string>
{
{ "Authorization", $"Bearer {token}" }
});
},
reason =>
{
response = reason;
Request.HttpClient.DefaultRequestHeaders.Clear(); // Clear headers
}
);
return new SigninResponse
{
Success = success,
Response = response
};
}
/// <summary>
/// Get the steam ticket and user info
/// </summary>
/// <returns>the steam ticket</returns>
private static async Task<string> GetSteamTicket()
{
Plugin.Log.Info("Getting steam ticket...");
return (await new SteamPlatformUserModel().GetUserAuthToken()).token;
}
/// <summary>
/// Login the user
/// </summary>
/// <param name="onSuccess">callback for successful login, returns the token</param>
/// <param name="onFail">callback for failed login</param>
/// <returns>an IEnumerator</returns>
public static async Task LoginUser(Action<string> onSuccess, Action<string> onFail)
{
if (_signedIn && !string.IsNullOrEmpty(_authToken))
{
onSuccess(_authToken);
return;
}
var ticketTask = GetSteamTicket();
await Task.Run(() => ticketTask.Wait());
var ticket = ticketTask.Result;
if (string.IsNullOrEmpty(ticket))
{
Plugin.Log.Error("Login failed :( no steam auth token");
onFail("No Steam Auth Token");
return;
}
Plugin.Log.Info("Logging in...");
var request = await Request.PostJsonAsync($"{PluginConfig.Instance.ApiUrl}/auth/login", new Dictionary<object, object> {
{ "ticket", ticket }
}, false);
if (request.IsSuccessStatusCode)
{
var authToken = request.Headers.GetValues("Authorization").First();
Plugin.Log.Info($"Login successful! auth token: {authToken}");
onSuccess(authToken);
_signedIn = true;
_authToken = authToken;
}
else
{
Plugin.Log.Error($"Login failed! body: {request.StatusCode}");
onFail($"Login failed: {request.StatusCode}");
_signedIn = false;
_authToken = null;
}
}
/// <summary>
/// Validates the auth token and logs out if it's invalid
/// </summary>
/// <returns>whether the token is valid</returns>
public static async Task<bool> ValidateAuthToken()
{
if (!_signedIn || string.IsNullOrEmpty(_authToken)) // If we're not signed in, return false
{
return false;
}
var request = await Request.PostJsonAsync($"{PluginConfig.Instance.ApiUrl}/auth/validate", new Dictionary<object, object> {
{ "token", _authToken }
}, false);
if (request.IsSuccessStatusCode)
{
return true;
}
else
{
_signedIn = false;
_authToken = null;
return false;
}
}
}
}

52
Mod/API/Request.cs Normal file
View File

@ -0,0 +1,52 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace ScoreTracker.API
{
internal class Request
{
internal static readonly HttpClient HttpClient = new HttpClient();
/// <summary>
/// Persist the given headers for all future requests
/// </summary>
/// <param name="headers">the headers to persist</param>
public static void PersistHeaders(Dictionary<string, string> headers)
{
HttpClient.DefaultRequestHeaders.Clear(); // Clear existing headers
foreach (var header in headers)
{
HttpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
}
}
/// <summary>
/// Create a POST request to the given URL with the given data
/// </summary>
/// <param name="url">the url to post to</param>
/// <param name="data">the data to post</param>
/// <param name="checkAuth">whether to check for authentication</param>
/// <returns>the task</returns>
public static async Task<HttpResponseMessage> PostJsonAsync(string url, Dictionary<object, object> json, bool checkAuth = true)
{
if (checkAuth)
{
var signinResponse = await Authentication.ValidateAndSignIn();
if (!signinResponse.Success)
{
throw new Exception($"Failed to log in: {signinResponse.Response}");
}
}
var jsonString = JsonConvert.SerializeObject(json, Formatting.None);
var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
// Send the POST request
var response = await HttpClient.PostAsync(url, content);
return response;
}
}
}

View File

@ -0,0 +1,16 @@
using IPA.Config.Stores;
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo(GeneratedStore.AssemblyVisibilityTarget)]
namespace ScoreTracker.Configuration
{
internal class PluginConfig
{
public static PluginConfig Instance { get; set; }
/*
* The URL of the API to use
*/
public virtual string ApiUrl { get; set; } = "https://beatsaber.fascinated.cc/api";
}
}

12
Mod/Directory.Build.props Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This file contains project properties used by the build. -->
<Project>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(RUNNING_IN_CI)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<DisableCopyToPlugins>true</DisableCopyToPlugins>
<DisableZipRelease>true</DisableZipRelease>
</PropertyGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More