This commit is contained in:
parent
1ec8248c6f
commit
c04a51de35
14
API/pom.xml
14
API/pom.xml
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
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 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 lombok.Getter;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.repository;
|
||||
package cc.fascinated.repository.mongo;
|
||||
|
||||
import cc.fascinated.model.score.Score;
|
||||
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 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 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)
|
||||
*/public class AuthTokenRepository {
|
||||
}
|
||||
*/
|
||||
public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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;
|
||||
|
@ -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
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
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
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>
|
||||
<ImportBSMTTargets>True</ImportBSMTTargets>
|
||||
<BSMTProjectType>BSIPA</BSMTProjectType>
|
||||
<LangVersion>8.0</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
13
Mod/Installers/AppInstaller.cs
Normal file
13
Mod/Installers/AppInstaller.cs
Normal file
@ -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 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");
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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" />
|
||||
|
2
Mod/ScoreTracker.csproj.DotSettings
Normal file
2
Mod/ScoreTracker.csproj.DotSettings
Normal 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>
|
@ -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" ]
|
||||
}
|
Reference in New Issue
Block a user