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

@ -70,6 +70,20 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId> <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency> </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 --> <!-- Dependencies -->
<dependency> <dependency>

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

@ -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) * @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;
}
}

@ -3,9 +3,11 @@ package cc.fascinated.common;
import kong.unirest.core.Headers; import kong.unirest.core.Headers;
import kong.unirest.core.HttpResponse; import kong.unirest.core.HttpResponse;
import kong.unirest.core.Unirest; import kong.unirest.core.Unirest;
import kong.unirest.core.UnirestParsingException;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import java.util.List; import java.util.List;
import java.util.Optional;
/** /**
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
@ -49,6 +51,7 @@ public class Request {
} }
response = Unirest.get(url).asObject(clazz); response = Unirest.get(url).asObject(clazz);
} }
response.getParsingError().ifPresent(e -> log.error("Failed to parse response", e));
return response; return response;
} }

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

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

@ -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) * @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);
}
} }

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

@ -4,33 +4,26 @@ import cc.fascinated.common.StringUtils;
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken; import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import java.util.UUID;
/** /**
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
*/ */
@AllArgsConstructor @AllArgsConstructor
@Getter @Getter
public class SteamAuthToken { @RedisHash(value = "AuthToken", timeToLive = 60 * 60 * 6) // 6 hours
/** public class AuthToken {
* The steam id of the user.
*/
private final String steamId;
/** /**
* The auth token of the user. * The auth token of the user.
*/ */
@Id
private final String authToken; private final String authToken;
/** /**
* Gets the SteamProfile from an auth token. * The id of the user.
*
* @param token The auth token.
* @return The SteamProfile.
*/ */
public static SteamAuthToken getFromAuthToken(SteamAuthenticateUserTicketToken token) { private final UUID userId;
return new SteamAuthToken(
token.getResponse().getParams().getSteamId(),
StringUtils.randomString(32)
);
}
} }

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

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

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

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

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

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

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

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

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

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

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

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

@ -3,9 +3,9 @@ package cc.fascinated.platform.impl;
import cc.fascinated.common.DateUtils; import cc.fascinated.common.DateUtils;
import cc.fascinated.common.MathUtils; import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.Score; import cc.fascinated.model.score.Score;
import cc.fascinated.model.token.ScoreSaberAccountToken; import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken; import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken; import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User; import cc.fascinated.model.user.User;
import cc.fascinated.model.user.history.HistoryPoint; import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.platform.CurvePoint; import cc.fascinated.platform.CurvePoint;

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

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

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

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

@ -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) * @author Fascinated (fascinated7)
*/public class AuthTokenRepository { */
} public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}

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

@ -2,11 +2,11 @@ package cc.fascinated.services;
import cc.fascinated.common.Request; import cc.fascinated.common.Request;
import cc.fascinated.exception.impl.BadRequestException; import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken; import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken; import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken; import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User; import cc.fascinated.model.user.User;
import cc.fascinated.repository.ScoreSaberLeaderboardRepository; import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse; import kong.unirest.core.HttpResponse;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;

@ -10,16 +10,16 @@ import cc.fascinated.model.score.Score;
import cc.fascinated.model.score.TotalScoresResponse; import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore; import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScoreResponse; import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScoreResponse;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken; import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken; import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken; import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
import cc.fascinated.model.user.User; import cc.fascinated.model.user.User;
import cc.fascinated.model.user.UserDTO; import cc.fascinated.model.user.UserDTO;
import cc.fascinated.model.user.history.HistoryPoint; import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.model.user.hmd.DeviceController; import cc.fascinated.model.user.hmd.DeviceController;
import cc.fascinated.model.user.hmd.DeviceHeadset; import cc.fascinated.model.user.hmd.DeviceHeadset;
import cc.fascinated.platform.Platform; import cc.fascinated.platform.Platform;
import cc.fascinated.repository.ScoreRepository; import cc.fascinated.repository.mongo.ScoreRepository;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

@ -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) * @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();
}
} }

@ -1,12 +1,16 @@
package cc.fascinated.services; package cc.fascinated.services;
import cc.fascinated.common.StringUtils;
import cc.fascinated.common.TimeUtils; import cc.fascinated.common.TimeUtils;
import cc.fascinated.exception.impl.BadRequestException; 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.ScoreSaberAccount;
import cc.fascinated.model.user.User; import cc.fascinated.model.user.User;
import cc.fascinated.model.user.history.HistoryPoint; 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.NonNull;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import net.jodah.expiringmap.ExpirationPolicy; import net.jodah.expiringmap.ExpirationPolicy;
@ -34,12 +38,24 @@ public class UserService {
@NonNull @NonNull
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* The auth token repository to use
*/
@NonNull
private final AuthTokenRepository authTokenRepository;
/** /**
* The ScoreSaber service to use * The ScoreSaber service to use
*/ */
@NonNull @NonNull
private final ScoreSaberService scoreSaberService; private final ScoreSaberService scoreSaberService;
/**
* The Steam service to use
*/
@NonNull
private final SteamService steamService;
/** /**
* The user cache to use * The user cache to use
*/ */
@ -50,9 +66,12 @@ public class UserService {
.build(); .build();
@Autowired @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.userRepository = userRepository;
this.authTokenRepository = authTokenRepository;
this.scoreSaberService = scoreSaberService; this.scoreSaberService = scoreSaberService;
this.steamService = steamService;
} }
/** /**
@ -110,6 +129,36 @@ public class UserService {
return 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 * Saves a user to the database
* *

@ -1,8 +1,8 @@
package cc.fascinated.websocket.impl; package cc.fascinated.websocket.impl;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken; import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken; import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
import cc.fascinated.model.token.ScoreSaberWebsocketDataToken; import cc.fascinated.model.token.scoresaber.ScoreSaberWebsocketDataToken;
import cc.fascinated.services.ScoreService; import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService; import cc.fascinated.services.UserService;
import cc.fascinated.websocket.Websocket; import cc.fascinated.websocket.Websocket;

@ -3,36 +3,31 @@ server:
address: 0.0.0.0 address: 0.0.0.0
port: 7500 port: 7500
# ScoreTracker Configuration
scoretracker:
steam:
api-key: "xxx"
# Spring Configuration # Spring Configuration
spring: spring:
data: data:
# Redis Configuration
redis:
host: localhost
port: 6379
database: 0
auth: ""
# MongoDB Configuration # MongoDB Configuration
mongodb: mongodb:
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017" uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
database: "bs-tracker" database: "bs-tracker"
auto-index-creation: true # Automatically create collection indexes 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 # Don't serialize null values by default with Jackson
jackson: jackson:
default-property-inclusion: non_null default-property-inclusion: non_null
# QuestDB Configuration
questdb:
host: localhost:9000
username: admin
password: quest
# DO NOT TOUCH BELOW # DO NOT TOUCH BELOW
management: management:
# Disable all actuator endpoints # Disable all actuator endpoints

107
Mod/API/Authentication.cs Normal 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

@ -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;
}
}
}

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

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

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

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

@ -1,6 +1,12 @@
using IPA; using IPA;
using ScoreTracker.Common;
using IPALogger = IPA.Logging.Logger; 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 namespace ScoreTracker
{ {
@ -8,24 +14,36 @@ namespace ScoreTracker
public class Plugin public class Plugin
{ {
internal static Plugin Instance { get; private set; } 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] [Init]
public Plugin(IPALogger logger) public Plugin(IPALogger logger, PluginMetadata metadata, Zenjector zenjector)
{ {
Instance = this; Instance = this;
Logger.Log = logger; // Setup the logger Log = logger; // Setup the logger
// Install our Zenject bindings
zenjector.Install<AppInstaller>(Location.App);
} }
[OnStart] [OnStart]
public void OnApplicationStart() 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] [OnExit]
public void OnApplicationQuit() public void OnApplicationQuit()
{ {
Logger.Log.Info("OnApplicationQuit"); Log.Info("OnApplicationQuit");
} }
} }

@ -40,6 +40,17 @@
<DisableZipRelease>True</DisableZipRelease> <DisableZipRelease>True</DisableZipRelease>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" /> <Reference Include="System.Xml.Linq" />
@ -86,13 +97,26 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath> <HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </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"> <Reference Include="UnityEngine.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath> <HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </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>
<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="Plugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup> </ItemGroup>
@ -109,6 +133,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>13.0.3</Version>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup /> <ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

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

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