why
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m32s

This commit is contained in:
Lee 2024-08-07 05:19:39 +01:00
parent 1ec8248c6f
commit c04a51de35
44 changed files with 729 additions and 106 deletions

View File

@ -70,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>

View File

@ -6,6 +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.mongodb.repository.config.EnableMongoRepositories;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.io.File;
@ -17,6 +19,8 @@ import java.util.Objects;
* @author Fascinated (fascinated7)
*/
@EnableScheduling
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
@EnableRedisRepositories(basePackages = "cc.fascinated.repository.redis")
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@Log4j2(topic = "Score Tracker")
public class Main {

View File

@ -1,4 +1,54 @@
package cc.fascinated.common;/**
package cc.fascinated.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/public class IPUtils {
}
*/
@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

@ -3,9 +3,11 @@ 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)
@ -49,6 +51,7 @@ public class Request {
}
response = Unirest.get(url).asObject(clazz);
}
response.getParsingError().ifPresent(e -> log.error("Failed to parse response", e));
return response;
}

View File

@ -1,4 +1,33 @@
package cc.fascinated.common;/**
package cc.fascinated.common;
import java.math.BigInteger;
/**
* @author Fascinated (fascinated7)
*/public class StringUtils {
*/
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

@ -1,4 +1,73 @@
package cc.fascinated.config;/**
* @author Fascinated (fascinated7)
*/public class RedisConfig {
}
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

@ -1,4 +1,63 @@
package cc.fascinated.controller;/**
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)
*/public class AuthenticationController {
*/
@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

@ -1,4 +1,50 @@
package cc.fascinated.log;/**
* @author Fascinated (fascinated7)
*/public class TransactionLogger {
}
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

@ -4,33 +4,26 @@ 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
public class SteamAuthToken {
/**
* The steam id of the user.
*/
private final String steamId;
@RedisHash(value = "AuthToken", timeToLive = 60 * 60 * 6) // 6 hours
public class AuthToken {
/**
* The auth token of the user.
*/
@Id
private final String authToken;
/**
* Gets the SteamProfile from an auth token.
*
* @param token The auth token.
* @return The SteamProfile.
* The id of the user.
*/
public static SteamAuthToken getFromAuthToken(SteamAuthenticateUserTicketToken token) {
return new SteamAuthToken(
token.getResponse().getParams().getSteamId(),
StringUtils.randomString(32)
);
}
private final UUID userId;
}

View File

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

View File

@ -1,7 +1,7 @@
package cc.fascinated.model.leaderboard;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import lombok.AllArgsConstructor;
import lombok.Getter;

View File

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

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 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

@ -1,4 +1,4 @@
package cc.fascinated.model.token.steam.token;
package cc.fascinated.model.token.steam;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

View File

@ -1,7 +1,7 @@
package cc.fascinated.model.user;
import cc.fascinated.common.DateUtils;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import lombok.AllArgsConstructor;
import lombok.Getter;

View File

@ -3,9 +3,9 @@ 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.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
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;

View File

@ -1,4 +1,4 @@
package cc.fascinated.repository;
package cc.fascinated.repository.mongo;
import cc.fascinated.model.Counter;
import org.springframework.data.mongodb.repository.MongoRepository;

View File

@ -1,4 +1,4 @@
package cc.fascinated.repository;
package cc.fascinated.repository.mongo;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;

View File

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

View File

@ -1,4 +1,4 @@
package cc.fascinated.repository;
package cc.fascinated.repository.mongo;
import cc.fascinated.model.user.User;
import org.springframework.data.mongodb.repository.MongoRepository;

View File

@ -1,4 +1,11 @@
package cc.fascinated.repository.mongo.redis;/**
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 class AuthTokenRepository {
}
*/
public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}

View File

@ -1,7 +1,7 @@
package cc.fascinated.services;
import cc.fascinated.model.Counter;
import cc.fascinated.repository.CounterRepository;
import cc.fascinated.repository.mongo.CounterRepository;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;

View File

@ -2,11 +2,11 @@ package cc.fascinated.services;
import cc.fascinated.common.Request;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
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.ScoreSaberLeaderboardRepository;
import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;

View File

@ -10,16 +10,16 @@ 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.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
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.ScoreRepository;
import cc.fascinated.repository.mongo.ScoreRepository;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;

View File

@ -1,4 +1,51 @@
package cc.fascinated.services;/**
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)
*/public class SteamService {
*/
@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

@ -1,12 +1,16 @@
package cc.fascinated.services;
import cc.fascinated.common.StringUtils;
import cc.fascinated.common.TimeUtils;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
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.UserRepository;
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;
@ -34,12 +38,24 @@ public class UserService {
@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
*/
@ -50,9 +66,12 @@ public class UserService {
.build();
@Autowired
public UserService(@NonNull UserRepository userRepository, @NonNull ScoreSaberService scoreSaberService) {
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;
}
/**
@ -110,6 +129,36 @@ public class UserService {
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
*

View File

@ -1,8 +1,8 @@
package cc.fascinated.websocket.impl;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.token.ScoreSaberWebsocketDataToken;
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;

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

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

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ScoreTracker.API
{
internal class Authentication
{
private static bool _signedIn = false;
private static string _authToken;
/// <summary>
/// Are we signed in?
/// </summary>
public static bool IsSignedIn()
{
return _signedIn;
}
/// <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($"{Consts.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($"{Consts.ApiUrl}/auth/validate", new Dictionary<object, object> {
{ "token", _authToken }
}, false);
if (request.IsSuccessStatusCode)
{
return true;
}
else
{
_signedIn = false;
_authToken = null;
return false;
}
}
}
}

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

@ -0,0 +1,82 @@
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
{
private static readonly HttpClient client = new HttpClient();
private class AuthHelper
{
public bool IsLoggedIn;
public string FailReason = "";
public async Task EnsureLoggedIn()
{
if (Authentication.IsSignedIn() && await Authentication.ValidateAuthToken())
{
return; // Already logged in with a valid token
}
await Authentication.LoginUser(
token => {
IsLoggedIn = true;
PersistHeaders(new Dictionary<string, string>
{
{ "Authorization", $"Bearer {token}" }
});
},
reason =>
{
FailReason = reason; // Store the reason for failure
client.DefaultRequestHeaders.Clear(); // Clear headers
}
);
}
}
/// <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)
{
client.DefaultRequestHeaders.Clear(); // Clear existing headers
foreach (var header in headers)
{
client.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 authHelper = new AuthHelper();
await authHelper.EnsureLoggedIn();
if (!authHelper.IsLoggedIn)
{
throw new Exception($"Failed to log in: {authHelper.FailReason}");
}
}
var jsonString = JsonConvert.SerializeObject(json, Formatting.None);
var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
// Send the POST request
var response = await client.PostAsync(url, content);
return response;
}
}
}

View File

@ -1,9 +0,0 @@
using IPALogger = IPA.Logging.Logger;
namespace ScoreTracker.Common
{
internal class Logger
{
public static IPALogger Log { get; set; }
}
}

7
Mod/Consts.cs Normal file
View File

@ -0,0 +1,7 @@
namespace ScoreTracker
{
internal class Consts
{
public static string ApiUrl = "http://localhost:7500";
}
}

View File

@ -4,5 +4,6 @@
<PropertyGroup>
<ImportBSMTTargets>True</ImportBSMTTargets>
<BSMTProjectType>BSIPA</BSMTProjectType>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,13 @@
using Zenject;
namespace ScoreTracker.Core
{
internal class AppInstaller : Installer
{
public override void InstallBindings()
{
Plugin.Container = Container;
}
}
}

View File

@ -1,6 +1,12 @@
using IPA;
using ScoreTracker.Common;
using IPALogger = IPA.Logging.Logger;
using SiraUtil.Zenject;
using IPA.Loader;
using Zenject;
using ScoreTracker.Core;
using System.Threading.Tasks;
using ScoreTracker.API;
using System.Collections.Generic;
namespace ScoreTracker
{
@ -8,24 +14,36 @@ namespace ScoreTracker
public class Plugin
{
internal static Plugin Instance { get; private set; }
internal static IPALogger Log { get; private set; }
internal static DiContainer Container; // Workaround to access the Zenject container in SceneLoaded
[Init]
public Plugin(IPALogger logger)
public Plugin(IPALogger logger, PluginMetadata metadata, Zenjector zenjector)
{
Instance = this;
Logger.Log = logger; // Setup the logger
Log = logger; // Setup the logger
// Install our Zenject bindings
zenjector.Install<AppInstaller>(Location.App);
}
[OnStart]
public void OnApplicationStart()
{
Logger.Log.Info("OnApplicationStart");
Log.Info("OnApplicationStart");
Task.Run(async () =>
{
await Request.PostJsonAsync("http://localhost:7500/boobies", new Dictionary<object, object> {
{ "boobies", "yes" }
});
});
}
[OnExit]
public void OnApplicationQuit()
{
Logger.Log.Info("OnApplicationQuit");
Log.Info("OnApplicationQuit");
}
}

View File

@ -40,6 +40,17 @@
<DisableZipRelease>True</DisableZipRelease>
</PropertyGroup>
<ItemGroup>
<Reference Include="BGNet, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Reference Include="BS_Utils, Version=1.12.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Plugins\BS_Utils.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="SiraUtil, Version=3.1.2.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Plugins\SiraUtil.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
@ -86,13 +97,26 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="UnityEngine.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Zenject, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Zenject.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Common\Logger.cs" />
<Compile Include="API\Authentication.cs" />
<Compile Include="API\Request.cs" />
<Compile Include="Consts.cs" />
<Compile Include="Installers\AppInstaller.cs" />
<Compile Include="Plugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
@ -109,6 +133,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>13.0.3</Version>
</PackageReference>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

View File

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/CSharpLanguageProject/LanguageLevel/@EntryValue">CSharp80</s:String></wpf:ResourceDictionary>

View File

@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/nike4613/ModSaber-MetadataFileSchema/master/Schema.json",
"id": "score-tracker-mod",
"id": "ScoreTracker",
"name": "ScoreTracker",
"author": "fascinated7",
"version": "0.0.1",
@ -9,7 +9,7 @@
"dependsOn": {
"BSIPA": "^4.2.2",
"BS Utils": "^1.12.0",
"BeatSaberMarkupLanguage": "^1.6.3"
"SiraUtil": "^3.1.0"
},
"loadAfter": [ "BS Utils", "BeatSaberMarkupLanguage" ]
"loadAfter": [ "BS Utils", "SiraUtil" ]
}