[+] Optimize upload photo

pull/22/head
Azalea 2024-03-05 17:56:16 -05:00
parent b9c063c41e
commit c9ac38de01
8 changed files with 165 additions and 172 deletions

View File

@ -62,12 +62,14 @@ game.ongeki.rival.rivals-max-count=10
## Maimai DX
## 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
## When uploading user portraits, limit the divMaxLength parameter. 1 divLength is about equal to the file size of 10kb.
## The default value is 32 (320kb), and the minimum value is 1 (10kb)
game.maimai2.userPhoto.divMaxLength=32
## User upload saving paths
paths.mai2-plays=data/upload/mai2/plays
paths.mai2-portrait=data/upload/mai2/portrait
paths.aqua-net-portrait=data/upload/net/portrait
## Logging
spring.servlet.multipart.max-file-size=10MB

View File

@ -13,7 +13,9 @@ import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import java.nio.file.Path
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
typealias RP = RequestParam
@ -55,11 +57,21 @@ val HTTP = HttpClient(CIO) {
fun millis() = System.currentTimeMillis()
val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun LocalDate.isoDate() = format(DATE_FORMAT)
fun LocalDateTime.isoDateTime() = format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// Encodings
fun Long.toHex(len: Int = 16): Str = "0x${this.toString(len).padStart(len, '0').uppercase()}"
fun Map<String, Any>.toUrl() = entries.joinToString("&") { (k, v) -> "$k=$v" }
// Map
operator fun <K, V> Map<K, V>.plus(map: Map<K, V>) =
(if (this is MutableMap) this else toMutableMap()).apply { putAll(map) }
operator fun <K, V> MutableMap<K, V>.plusAssign(map: Map<K, V>) { putAll(map) }
suspend fun <T> async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() }
suspend fun <T> async(block: suspend kotlinx.coroutines.CoroutineScope.() -> T): T = withContext(Dispatchers.IO) { block() }
// Paths
fun path(part1: Str, vararg parts: Str) = Path.of(part1, *parts)
fun Str.path() = Path.of(this)
operator fun Path.div(part: Str) = resolve(part)

View File

@ -0,0 +1,21 @@
package icu.samnyan.aqua.net.utils
import ext.path
import jakarta.annotation.PostConstruct
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
@Configuration
@ConfigurationProperties(prefix = "paths")
class PathProps {
var mai2Plays: String = "data/upload/mai2/plays"
var mai2Portrait: String = "data/upload/mai2/portrait"
var aquaNetPortrait: String = "data/upload/net/portrait"
@PostConstruct
fun init() {
mai2Plays = mai2Plays.path().apply { toFile().mkdirs() }.toString()
mai2Portrait = mai2Portrait.path().apply { toFile().mkdirs() }.toString()
aquaNetPortrait = aquaNetPortrait.path().apply { toFile().mkdirs() }.toString()
}
}

View File

@ -34,7 +34,7 @@ public class GetUserPortraitHandler implements BaseHandler {
public GetUserPortraitHandler(BasicMapper mapper,
@Value("${game.maimai2.userPhoto.enable:true}") boolean enable,
@Value("${game.maimai2.userPhoto.picSavePath:data/userPhoto}") String picSavePath) {
@Value("${paths.mai2-portrait:data/userPhoto}") String picSavePath) {
this.mapper = mapper;
this.picSavePath = picSavePath;
this.enable = enable;

View File

@ -1,70 +0,0 @@
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.UploadUserPhoto;
import icu.samnyan.aqua.sega.maimai2.model.request.data.UserPhoto;
import icu.samnyan.aqua.sega.util.jackson.BasicMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Map;
/**
* @author samnyan (privateamusement@protonmail.com)
*/
@Component("Maimai2UploadUserPhotoHandler")
public class UploadUserPhotoHandler implements BaseHandler {
private static final Logger logger = LoggerFactory.getLogger(UploadUserPhotoHandler.class);
private final BasicMapper mapper;
public UploadUserPhotoHandler(BasicMapper mapper) {
this.mapper = mapper;
}
@Override
public String handle(Map<String, Object> 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.
*/
UploadUserPhoto uploadUserPhoto = mapper.convert(request, UploadUserPhoto.class);
UserPhoto userPhoto = uploadUserPhoto.getUserPhoto();
long userId = userPhoto.getUserId();
int trackNo = userPhoto.getTrackNo();
int divNumber = userPhoto.getDivNumber();
int divLength = userPhoto.getDivLength();
String divData = userPhoto.getDivData();
try {
String tmp_filename = userId + "-" + trackNo + ".tmp";
byte[] imageData = Base64.getDecoder().decode(divData);
Files.write(Paths.get("data/" + tmp_filename), imageData, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
if (divNumber == (divLength - 1)) {
String filename = userId + "-" + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + ".jpg";
Files.move(Paths.get("data/" + tmp_filename), Paths.get("data/" + filename));
}
} catch (IOException e) {
logger.error("Result: User photo save failed", e);
}
logger.info("Result: User photo saved");
return "{\"returnCode\":1,\"apiName\":\"com.sega.maimai2servlet.api.UploadUserPhotoApi\"}";
}
}

View File

@ -0,0 +1,51 @@
package icu.samnyan.aqua.sega.maimai2.handler.impl
import ext.div
import ext.isoDateTime
import ext.path
import icu.samnyan.aqua.sega.maimai2.handler.BaseHandler
import icu.samnyan.aqua.sega.maimai2.model.request.UploadUserPhoto
import icu.samnyan.aqua.sega.util.jackson.BasicMapper
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.io.IOException
import java.nio.file.Files
import java.nio.file.StandardOpenOption.APPEND
import java.nio.file.StandardOpenOption.CREATE
import java.time.LocalDateTime
import java.util.*
@Component("Maimai2UploadUserPhotoHandler")
class UploadUserPhotoHandler(private val mapper: BasicMapper) : BaseHandler {
val tmpDir = "data/tmp".path().apply { toFile().mkdirs() }
val uploadDir = "data/upload/mai2/plays".path().apply { toFile().mkdirs() }
override fun handle(request: Map<String, Any>): String {
// Maimai DX sends split 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 scorecards in a single day.
val uploadUserPhoto = mapper.convert(request, UploadUserPhoto::class.java)
val up = uploadUserPhoto.userPhoto
try {
val tmpFile = tmpDir / "${up.userId}-${up.trackNo}.tmp"
Files.write(tmpFile, Base64.getDecoder().decode(up.divData), CREATE, APPEND)
if (up.divNumber == (up.divLength - 1))
Files.move(tmpFile, uploadDir / "${up.userId}-${LocalDateTime.now().isoDateTime()}.jpg")
} catch (e: IOException) {
logger.error("Result: User photo save failed", e)
}
logger.info("Result: User photo saved")
return """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.UploadUserPhotoApi"}"""
}
companion object {
private val logger: Logger = LoggerFactory.getLogger(UploadUserPhotoHandler::class.java)
}
}

View File

@ -1,98 +0,0 @@
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;
private final long divMaxLength;
public UploadUserPortraitHandler(BasicMapper mapper,
@Value("${game.maimai2.userPhoto.enable:true}") boolean enable,
@Value("${game.maimai2.userPhoto.picSavePath:data/userPhoto}") String picSavePath,
@Value("${game.maimai2.userPhoto.divMaxLength:32}") long divMaxLength) {
this.mapper = mapper;
this.picSavePath = picSavePath;
this.enable = enable;
this.divMaxLength = divMaxLength;
if (enable) {
try {
Files.createDirectories(Paths.get(picSavePath));
} catch (Exception ignored) {
}
}
}
@Override
public String handle(Map<String, Object> 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();
if (divLength > divMaxLength) {
logger.warn(String.format("stop user %d uploading photo data because divLength(%d) > divMaxLength(%d)", userId, divLength, divMaxLength));
return "{\"returnCode\":-1,\"apiName\":\"com.sega.maimai2servlet.api.UploadUserPortraitApi\"}";
}
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\"}";
}
}

View File

@ -0,0 +1,75 @@
package icu.samnyan.aqua.sega.maimai2.handler.impl
import ext.div
import ext.path
import icu.samnyan.aqua.net.utils.PathProps
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.file.Files
import java.nio.file.StandardCopyOption.REPLACE_EXISTING
import java.nio.file.StandardOpenOption.APPEND
import java.nio.file.StandardOpenOption.CREATE
import java.util.*
import kotlin.io.path.writeText
@Component("Maimai2UploadUserPortraitHandler")
class UploadUserPortraitHandler(
val mapper: BasicMapper,
paths: PathProps,
@param:Value("\${game.maimai2.userPhoto.enable:true}") val enable: Boolean,
@param:Value("\${game.maimai2.userPhoto.divMaxLength:32}") val divMaxLength: Long
) : BaseHandler {
val path = paths.mai2Portrait.path()
val success = """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.UploadUserPortraitApi"}"""
val fail = """{"returnCode":-1,"apiName":"com.sega.maimai2servlet.api.UploadUserPortraitApi"}"""
override fun handle(request: Map<String, Any>): String {
if (!enable) return success
// Maimai DX sends split 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 scorecards in a single day.
val up = mapper.convert(request, UploadUserPortrait::class.java).userPortrait
val id = up.userId
val num = up.divNumber
val len = up.divLength
if (len > divMaxLength) {
logger.warn("stop user $id uploading photo data because divLength($len) > divMaxLength($divMaxLength)")
return fail
}
try {
val tmp = path / "$id-up.tmp"
if (num == 0) Files.deleteIfExists(tmp)
Files.write(tmp, Base64.getDecoder().decode(up.divData), CREATE, APPEND)
logger.info("> User photo $id data ${num + 1}/${len}")
if (num == (len - 1)) {
Files.move(tmp, path / "$id-up.jpg", REPLACE_EXISTING)
up.divData = ""
(path / "$id-up.json").writeText(mapper.write(up))
logger.info("> Saved user $id photo data")
}
} catch (e: IOException) {
logger.error("> User photo save failed", e)
}
return success
}
companion object {
private val logger: Logger = LoggerFactory.getLogger(UploadUserPortraitHandler::class.java)
}
}