diff --git a/config/application.properties b/config/application.properties index f8139640..2ecc2593 100644 --- a/config/application.properties +++ b/config/application.properties @@ -47,6 +47,10 @@ game.ongeki.version=1.05.00 ## Set this true if you are using old version of Splash network patch and have no other choice. ## This is a dirty workaround. If enabled, you probably won't able to play other versions. game.maimai2.splash-old-patch=false +## Allow users take photo as their avatar/portrait photo. +game.maimai2.userPhoto.enable=true +## Specify folder path that user portrait photo and its (.json) data save to. +game.maimai2.userPhoto.picSavePath=data/userPhoto ## Logging spring.servlet.multipart.max-file-size=10MB diff --git a/docs/game_specific_notes.md b/docs/game_specific_notes.md index 16bdc6dd..d29ed82d 100644 --- a/docs/game_specific_notes.md +++ b/docs/game_specific_notes.md @@ -59,7 +59,6 @@ Only JP variant is supported. ### Non-working features * KOP related -* User portrait * Tournament mode ### Additional notes diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/controller/Maimai2ServletController.java b/src/main/java/icu/samnyan/aqua/sega/maimai2/controller/Maimai2ServletController.java index 04a08a5a..1d62afef 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/controller/Maimai2ServletController.java +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/controller/Maimai2ServletController.java @@ -47,6 +47,8 @@ public class Maimai2ServletController { private final UploadUserPlaylogHandler uploadUserPlaylogHandler; private final GetGameNgMusicIdHandler getGameNgMusicIdHandler; private final GetUserFriendSeasonRankingHandler getUserFriendSeasonRankingHandler; + private final GetUserPortraitHandler getUserPortraitHandler; + private final UploadUserPortraitHandler uploadUserPortraitHandler; private final CMGetUserPreviewHandler cmGetUserPreviewHandler; private final CMGetSellingCardHandler cmGetSellingCardHandler; private final GetUserCardPrintErrorHandler getUserCardPrintErrorHandler; @@ -60,7 +62,7 @@ public class Maimai2ServletController { GetUserLoginBonusHandler getUserLoginBonusHandler, GetUserMapHandler getUserMapHandler, GetUserFavoriteHandler getUserFavoriteHandler, GetUserCardHandler getUserCardHandler, GetUserMusicHandler getUserMusicHandler, GetUserRatingHandler getUserRatingHandler, GetUserRegionHandler getUserRegionHandler, GetGameChargeHandler getGameChargeHandler, GetUserChargeHandler getUserChargeHandler, GetUserCourseHandler getUserCourseHandler, UploadUserPhotoHandler uploadUserPhotoHandler, - UploadUserPlaylogHandler uploadUserPlaylogHandler, GetGameNgMusicIdHandler getGameNgMusicIdHandler, GetUserFriendSeasonRankingHandler getUserFriendSeasonRankingHandler, + UploadUserPlaylogHandler uploadUserPlaylogHandler, UploadUserPortraitHandler uploadUserPortraitHandler, GetGameNgMusicIdHandler getGameNgMusicIdHandler,GetUserPortraitHandler getUserPortraitHandler, GetUserFriendSeasonRankingHandler getUserFriendSeasonRankingHandler, CMGetUserPreviewHandler cmGetUserPreviewHandler, CMGetSellingCardHandler cmGetSellingCardHandler, GetUserCardPrintErrorHandler getUserCardPrintErrorHandler, CMGetUserCharacterHandler cmGetUserCharacterHandler, UpsertUserPrintHandler upsertUserPrintHandler) { this.getGameSettingHandler = getGameSettingHandler; @@ -93,6 +95,8 @@ public class Maimai2ServletController { this.uploadUserPlaylogHandler = uploadUserPlaylogHandler; this.getGameNgMusicIdHandler = getGameNgMusicIdHandler; this.getUserFriendSeasonRankingHandler = getUserFriendSeasonRankingHandler; + this.getUserPortraitHandler = getUserPortraitHandler; + this.uploadUserPortraitHandler = uploadUserPortraitHandler; this.cmGetUserPreviewHandler = cmGetUserPreviewHandler; this.cmGetSellingCardHandler = cmGetSellingCardHandler; this.getUserCardPrintErrorHandler = getUserCardPrintErrorHandler; @@ -188,10 +192,9 @@ public class Maimai2ServletController { return getUserOptionHandler.handle(request); } - // No support @PostMapping("GetUserPortraitApi") public String getUserPortraitHandler(@ModelAttribute Map request) throws JsonProcessingException { - return "{\"length\":0,\"userPortraitList\":[]}"; + return getUserPortraitHandler.handle(request); } @PostMapping("GetUserPreviewApi") @@ -226,10 +229,9 @@ public class Maimai2ServletController { return uploadUserPlaylogHandler.handle(request); } - // No support, return error code @PostMapping("UploadUserPortraitApi") public String uploadUserPortraitHandler(@ModelAttribute Map request) throws JsonProcessingException { - return "{\"returnCode\":-1,\"apiName\":\"com.sega.maimai2servlet.api.UploadUserPortraitApi\"}"; + return uploadUserPortraitHandler.handle(request); } @PostMapping("UserLoginApi") @@ -299,7 +301,7 @@ public class Maimai2ServletController { public String getUserFriendSeasonRankingHandler(@ModelAttribute Map request) throws JsonProcessingException { return getUserFriendSeasonRankingHandler.handle(request); } - + @PostMapping("Ping") String ping(@ModelAttribute Map request) { return "{\"returnCode\":\"1\"}"; diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/GetUserPortraitHandler.java b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/GetUserPortraitHandler.java new file mode 100644 index 00000000..d4928da1 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/GetUserPortraitHandler.java @@ -0,0 +1,103 @@ +package icu.samnyan.aqua.sega.maimai2.handler.impl; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonProcessingException; +import icu.samnyan.aqua.sega.maimai2.handler.BaseHandler; +import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPortrait; +import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait; +import icu.samnyan.aqua.sega.util.jackson.BasicMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.codec.Utf8; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.*; + +/** + * @author samnyan (privateamusement@protonmail.com) + */ +@Component("Maimai2GetUserPortraitHandler") +public class GetUserPortraitHandler implements BaseHandler { + private static final Logger logger = LoggerFactory.getLogger(GetUserPortraitHandler.class); + + private final BasicMapper mapper; + private final String picSavePath; + private final boolean enable; + + public GetUserPortraitHandler(BasicMapper mapper, @Value("${game.maimai2.userPhoto.enable:}") boolean enable, @Value("${game.maimai2.userPhoto.picSavePath:}") String picSavePath) { + this.mapper = mapper; + this.picSavePath = picSavePath; + this.enable = enable; + + if (enable) { + try { + Files.createDirectories(Paths.get(picSavePath)); + } catch (Exception ignored) { + } + } + } + + @Override + public String handle(Map request) throws JsonProcessingException { + if (enable) { + var userId = ((Number) request.get("userId")).longValue(); + var list = new ArrayList(); + + try { + var filePath = Paths.get(picSavePath, userId + "-up.jpg"); + + var templateJsonStr = Files.readString(Paths.get(picSavePath, userId + "-up.json")); + var templateUserPortrait = mapper.read(templateJsonStr, UserPortrait.class); + + var buffer = new byte[10240]; + + if (Files.exists(filePath)) { + var stream = new FileInputStream(filePath.toFile()); + while (stream.available() > 0) { + var read = stream.read(buffer, 0, 10240); + + var encodeBuffer = read == 10240 ? buffer : Arrays.copyOfRange(buffer, 0, read); + + var userPortrait = new UserPortrait(); + + userPortrait.setFileName(templateUserPortrait.getFileName()); + userPortrait.setPlaceId(templateUserPortrait.getPlaceId()); + userPortrait.setUserId(templateUserPortrait.getUserId()); + userPortrait.setClientId(templateUserPortrait.getClientId()); + userPortrait.setUploadDate(templateUserPortrait.getUploadDate()); + userPortrait.setDivData(Utf8.decode(Base64.getEncoder().encode(encodeBuffer))); + + userPortrait.setDivNumber(list.size()); + + list.add(userPortrait); + } + + stream.close(); + for (var i = 0; i < list.size(); i++) { + var userPortrait = list.get(i); + userPortrait.setDivLength(list.size()); + } + + var map = new HashMap(); + map.put("length", list.size()); + map.put("userPortraitList", list); + + var respJson = mapper.write(map); + return respJson; + } + } catch (Exception e) { + logger.error("Result: User photo save failed", e); + } + } + + return "{\"length\":0,\"userPortraitList\":[]}"; + } +} diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/UploadUserPortraitHandler.java b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/UploadUserPortraitHandler.java new file mode 100644 index 00000000..8239ec93 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/handler/impl/UploadUserPortraitHandler.java @@ -0,0 +1,88 @@ +package icu.samnyan.aqua.sega.maimai2.handler.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import icu.samnyan.aqua.sega.maimai2.handler.BaseHandler; +import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPortrait; +import icu.samnyan.aqua.sega.util.jackson.BasicMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Base64; +import java.util.Map; + +/** + * @author samnyan (privateamusement@protonmail.com) + */ +@Component("Maimai2UploadUserPortraitHandler") +public class UploadUserPortraitHandler implements BaseHandler { + + private static final Logger logger = LoggerFactory.getLogger(UploadUserPortraitHandler.class); + + private final BasicMapper mapper; + + private final String picSavePath; + private final boolean enable; + + public UploadUserPortraitHandler(BasicMapper mapper, @Value("${game.maimai2.userPhoto.enable:}") boolean enable, @Value("${game.maimai2.userPhoto.picSavePath:}") String picSavePath) { + this.mapper = mapper; + this.picSavePath = picSavePath; + this.enable = enable; + + if (enable) { + try { + Files.createDirectories(Paths.get(picSavePath)); + } catch (Exception ignored) { + } + } + } + + @Override + public String handle(Map request) throws JsonProcessingException { + /* + Maimai DX sends splited base64 data for one jpeg image. + So, make a temp file and keep append bytes until last part received. + If finished, rename it to other name so user can keep save multiple score cards in a single day. + */ + + if (enable) { + var uploadUserPhoto = mapper.convert(request, UploadUserPortrait.class); + var userPhoto = uploadUserPhoto.getUserPortrait(); + + long userId = userPhoto.getUserId(); + int divNumber = userPhoto.getDivNumber(); + int divLength = userPhoto.getDivLength(); + String divData = userPhoto.getDivData(); + + try { + var tmp_filename = Paths.get(picSavePath, userId + "-up.tmp"); + if (divNumber == 0) + Files.deleteIfExists(tmp_filename); + + byte[] imageData = Base64.getDecoder().decode(divData); + Files.write(tmp_filename, imageData, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + + logger.info(String.format("received user %d photo data %d/%d", userId, divNumber + 1, divLength)); + + if (divNumber == (divLength - 1)) { + var filename = Paths.get(picSavePath, userId + "-up.jpg"); + Files.move(tmp_filename, filename, StandardCopyOption.REPLACE_EXISTING); + + userPhoto.setDivData(""); + var userPortaitMetaJson = mapper.write(userPhoto); + var json_filename = Paths.get(picSavePath, userId + "-up.json"); + Files.write(json_filename, userPortaitMetaJson.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + + logger.info(String.format("saved user %d photo data", userId)); + } + } catch (IOException e) { + logger.error("Result: User photo save failed", e); + } + } + return "{\"returnCode\":1,\"apiName\":\"com.sega.maimai2servlet.api.UploadUserPortraitApi\"}"; + } +} diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/UploadUserPortrait.java b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/UploadUserPortrait.java new file mode 100644 index 00000000..f1810d67 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/UploadUserPortrait.java @@ -0,0 +1,18 @@ +package icu.samnyan.aqua.sega.maimai2.model.request; + +import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPortrait; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * @author samnyan (privateamusement@protonmail.com) + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UploadUserPortrait implements Serializable { + private UserPortrait userPortrait; +} diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/data/UserPortrait.java b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/data/UserPortrait.java new file mode 100644 index 00000000..ac7be464 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/request/data/UserPortrait.java @@ -0,0 +1,24 @@ +package icu.samnyan.aqua.sega.maimai2.model.request.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * @author samnyan (privateamusement@protonmail.com) + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserPortrait implements Serializable { + private long userId; + private int divNumber; + private int divLength; + private String divData; + private int placeId; + private String clientId; + private String uploadDate; + private String fileName; +} diff --git a/src/main/java/icu/samnyan/aqua/sega/util/jackson/BasicMapper.java b/src/main/java/icu/samnyan/aqua/sega/util/jackson/BasicMapper.java index 27481c6d..25f48388 100644 --- a/src/main/java/icu/samnyan/aqua/sega/util/jackson/BasicMapper.java +++ b/src/main/java/icu/samnyan/aqua/sega/util/jackson/BasicMapper.java @@ -38,6 +38,14 @@ public class BasicMapper { } + public T read(String jsonStr, Class toClass) throws JsonProcessingException { + return mapper.readValue(jsonStr, toClass); + } + + public T read(String jsonStr, TypeReference toValueTypeRef) throws JsonProcessingException { + return mapper.readValue(jsonStr, toValueTypeRef); + } + public T convert(Object map, Class toClass) { return mapper.convertValue(map, toClass); }