From 3e6c0b41599086d2ceb2aef56f3bf6ca770cec8b Mon Sep 17 00:00:00 2001 From: Menci Date: Wed, 8 Oct 2025 04:21:01 +0800 Subject: [PATCH] feat: user management APIs (#184) --- src/main/java/ext/Ext.kt | 1 + src/main/java/icu/samnyan/aqua/net/Fedy.kt | 116 +++++++++++++++--- .../icu/samnyan/aqua/net/UserRegistrar.kt | 17 ++- .../icu/samnyan/aqua/net/db/AquaNetUser.kt | 3 +- 4 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/main/java/ext/Ext.kt b/src/main/java/ext/Ext.kt index 3f34367d..f991d4b4 100644 --- a/src/main/java/ext/Ext.kt +++ b/src/main/java/ext/Ext.kt @@ -40,6 +40,7 @@ import kotlin.reflect.jvm.jvmErasure typealias RP = RequestParam typealias RB = RequestBody +typealias RT = RequestPart typealias RH = RequestHeader typealias PV = PathVariable typealias API = RequestMapping diff --git a/src/main/java/icu/samnyan/aqua/net/Fedy.kt b/src/main/java/icu/samnyan/aqua/net/Fedy.kt index ade7cc2b..712d1133 100644 --- a/src/main/java/icu/samnyan/aqua/net/Fedy.kt +++ b/src/main/java/icu/samnyan/aqua/net/Fedy.kt @@ -1,12 +1,14 @@ package icu.samnyan.aqua.net import ext.* +import icu.samnyan.aqua.net.components.EmailProperties import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Configuration import org.springframework.web.bind.annotation.RestController import java.security.MessageDigest import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.components.JWT +import icu.samnyan.aqua.net.db.AquaNetUser import icu.samnyan.aqua.net.db.AquaUserServices import icu.samnyan.aqua.net.games.mai2.Mai2Import import icu.samnyan.aqua.net.games.ExportOptions @@ -18,13 +20,18 @@ import org.springframework.transaction.support.TransactionTemplate import icu.samnyan.aqua.sega.maimai2.model.Mai2UserDataRepo import icu.samnyan.aqua.net.games.GenericUserDataRepo import icu.samnyan.aqua.net.games.IUserData +import icu.samnyan.aqua.net.utils.PathProps import icu.samnyan.aqua.sega.chusan.model.Chu3UserDataRepo import icu.samnyan.aqua.sega.general.dao.CardRepository import icu.samnyan.aqua.sega.general.model.Card import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo +import org.springframework.web.multipart.MultipartFile import java.util.concurrent.CompletableFuture +import kotlin.io.path.getLastModifiedTime +import kotlin.io.path.isRegularFile +import kotlin.io.path.writeBytes @Configuration @ConfigurationProperties(prefix = "aqua-net.fedy") @@ -34,12 +41,21 @@ class FedyProps { var remote: String = "" } +data class UserProfilePicture(val url: Str, val lastUpdatedMs: Long) +data class UserBasicInfo( + val auId: Long, val ghostExtId: Long, val registrationTimeMs: Long, + val username: Str, val displayName: Str, val email: Str, val passwordHash: Str, val profileBio: Str, + val profilePicture: UserProfilePicture?, +) + +private data class UserUpdatedEvent(val user: UserBasicInfo, val isNewlyCreated: Bool) private data class CardCreatedEvent(val luid: Str, val extId: Long) private data class CardLinkedEvent(val luid: Str, val oldExtId: Long?, val ghostExtId: Long, val migratedGames: List) private data class CardUnlinkedEvent(val luid: Str) private data class DataUpdatedEvent(val extId: Long, val isGhostCard: Bool, val game: Str, val removeOldData: Bool) private data class FedyEvent( + var userUpdated: UserUpdatedEvent? = null, var cardCreated: CardCreatedEvent? = null, var cardLinked: CardLinkedEvent? = null, var cardUnlinked: CardUnlinkedEvent? = null, @@ -47,10 +63,11 @@ private data class FedyEvent( ) @RestController -@API("/api/v2/fedy") +@API("/api/v2/fedy", consumes = ["multipart/form-data"]) class Fedy( val jwt: JWT, val us: AquaUserServices, + val emailProps: EmailProperties, val cardRepo: CardRepository, val cardService: CardService, val mai2Import: Mai2Import, @@ -61,6 +78,7 @@ class Fedy( val ongekiUserDataRepo: OgkUserDataRepo, val waccaUserDataRepo: WcUserRepo, val props: FedyProps, + val paths: PathProps, val transactionManager: PlatformTransactionManager ) { val transaction by lazy { TransactionTemplate(transactionManager) } @@ -80,26 +98,87 @@ class Fedy( } finally { suppressEvents.set(old) } } + data class FedyErr(val code: Int, val message: Str) + + data class UserPullReq(val auId: Long) + data class UserPullRes(val user: UserBasicInfo?) + @API("/user/pull") + fun handleUserPull(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: UserPullReq): UserPullRes = handleFedy(key) { + UserPullRes(us.userRepo.findByAuId(req.auId)?.fedyBasicInfo()) + } + + data class UserLookupReq(val username: Str?, val email: Str?) + data class UserLookupRes(val user: UserBasicInfo?) + @API("/user/lookup") + fun handleUserLogin(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: UserLookupReq): UserLookupRes = handleFedy(key) { + UserLookupRes(user = + (req.username?.let { us.userRepo.findByUsernameIgnoreCase(it) } ?: req.email?.let {us.userRepo.findByEmailIgnoreCase(it) }) + ?.takeIf { it.emailConfirmed || !emailProps.enable } + ?.fedyBasicInfo() + ) + } + + data class UserRegisterReq(val username: Str, val email: Str, val password: Str) + data class UserRegisterRes(val error: FedyErr? = null, val user: UserBasicInfo? = null) + @API("/user/register") + fun handleUserRegister(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: UserRegisterReq): UserRegisterRes = handleFedy(key) { + { + UserRegisterRes(user = us.create(req.username, req.email, req.password, "", emailConfirmed = true).fedyBasicInfo()) + } caught { UserRegisterRes(error = it) } + } + + data class UserUpdateReq(val auId: Long, val fields: Map?) + data class UserUpdateRes(val error: FedyErr? = null, val user: UserBasicInfo? = null) + @API("/user/update") + fun handleUserUpdate(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: UserUpdateReq, @RT(PFP_PART) pfpFile: MultipartFile?): UserUpdateRes = handleFedy(key) { + { + val ru = us.userRepo.findByAuId(req.auId) ?: (404 - "User not found") + val fields = req.fields?.filterValues { it != null }?.mapValues { it.value as Str } ?: emptyMap() + fields.forEach { (k, v) -> + if (k == "email") { ru.email = us.validateEmail(v) } + else us.update(ru, k, v) + } + pfpFile?.run { + val mime = TIKA.detect(pfpFile.bytes).takeIf { it.startsWith("image/") } ?: (400 - "Invalid file type") + val name = "${ru.auId}${MIMES.forName(mime)?.extension ?: ".jpg"}" + (paths.aquaNetPortrait.path() / name).writeBytes(bytes) + ru.profilePicture = name + } + us.userRepo.save(ru) + if (fields.containsKey("pwHash") ?: false) { us.clearAllSessions(ru) } + UserUpdateRes(user = ru.fedyBasicInfo()) + } caught { UserUpdateRes(error = it) } + } + + private fun AquaNetUser.fedyBasicInfo() = UserBasicInfo( + auId, ghostCard.extId, regTime, + username, displayName, email, pwHash, profileBio ?: "", + profilePicture + ?.let { paths.aquaNetPortrait.path() / it }?.takeIf { it.isRegularFile() } + ?.let { UserProfilePicture( + url = "/uploads/net/portrait/${profilePicture}", + lastUpdatedMs = it.getLastModifiedTime().toMillis() + ) } + ) + data class DataPullReq(val extId: Long, val game: Str, val exportOptions: ExportOptions) - data class DataPullRes(val error: DataPullErr? = null, val result: Any? = null) - data class DataPullErr(val code: Int, val message: Str) + data class DataPullRes(val error: FedyErr? = null, val result: Any? = null) @API("/data/pull") - fun handleDataPull(@RH(KEY_HEADER) key: Str, @RB req: DataPullReq): DataPullRes = handleFedy(key) { + fun handleDataPull(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPullReq): DataPullRes = handleFedy(key) { val card = cardRepo.findByExtId(req.extId).orElse(null) ?: (404 - "Card with extId ${req.extId} not found") - fun caught(block: () -> Any) = - try { DataPullRes(result = block()) } - catch (e: ApiException) { DataPullRes(error = DataPullErr(code = e.code, message = e.message.toString())) } - when (req.game) { - "mai2" -> caught { mai2Import.export(card, req.exportOptions) } - else -> 406 - "Unsupported game" - } + { + DataPullRes(result = when (req.game) { + "mai2" -> mai2Import.export(card, req.exportOptions) + else -> 406 - "Unsupported game" + }) + } caught { DataPullRes(error = it) } } data class DataPushReq(val extId: Long, val game: Str, val data: JDict, val removeOldData: Bool) @Suppress("UNCHECKED_CAST") @API("/data/push") - fun handleDataPush(@RH(KEY_HEADER) key: Str, @RB req: DataPushReq): Any = handleFedy(key) { + fun handleDataPush(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: DataPushReq): Any = handleFedy(key) { val extId = req.extId fun> removeOldData(repo: UserRepo) { val oldData = repo.findByCard_ExtId(extId) @@ -126,7 +205,7 @@ class Fedy( data class CardResolveReq(val luid: Str, val pairedLuid: Str?, val createIfNotFound: Bool) data class CardResolveRes(val extId: Long, val isGhostCard: Bool, val isNewlyCreated: Bool, val isPairedLuidDiverged: Bool) @API("/card/resolve") - fun handleCardResolve(@RH(KEY_HEADER) key: Str, @RB req: CardResolveReq): CardResolveRes = handleFedy(key) { + fun handleCardResolve(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: CardResolveReq): CardResolveRes = handleFedy(key) { var card = cardService.tryLookup(req.luid) var isNewlyCreated = false if (card != null) { @@ -163,7 +242,7 @@ class Fedy( data class CardLinkReq(val auId: Long, val luid: Str) @API("/card/link") - fun handleCardLink(@RH(KEY_HEADER) key: Str, @RB req: CardLinkReq): Any = handleFedy(key) { + fun handleCardLink(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: CardLinkReq): Any = handleFedy(key) { val ru = us.userRepo.findByAuId(req.auId) ?: (404 - "User not found") var card = cardService.tryLookup(req.luid) if (card == null) { @@ -185,7 +264,7 @@ class Fedy( data class CardUnlinkReq(val auId: Long, val luid: Str) @API("/card/unlink") - fun handleCardUnlink(@RH(KEY_HEADER) key: Str, @RB req: CardUnlinkReq): Any = handleFedy(key) { + fun handleCardUnlink(@RH(KEY_HEADER) key: Str, @RT(REQ_PART) req: CardUnlinkReq): Any = handleFedy(key) { val card = cardService.tryLookup(req.luid) val cu = card?.aquaUser ?: return@handleFedy SUCCESS // Nothing to do @@ -197,6 +276,7 @@ class Fedy( log.info("Fedy /card/unlink : Unlinked card ${card.id} (${card.luid}) from user ${cu.auId} (${cu.username})") } + fun onUserUpdated(u: AquaNetUser, isNew: Bool = false) = maybeNotifyAsync(FedyEvent(userUpdated = UserUpdatedEvent(u.fedyBasicInfo(), isNew))) fun onCardCreated(luid: Str, extId: Long) = maybeNotifyAsync(FedyEvent(cardCreated = CardCreatedEvent(luid, extId))) fun onCardLinked(luid: Str, oldExtId: Long?, ghostExtId: Long, migratedGames: List) = maybeNotifyAsync(FedyEvent(cardLinked = CardLinkedEvent(luid, oldExtId, ghostExtId, migratedGames))) fun onCardUnlinked(luid: Str) = maybeNotifyAsync(FedyEvent(cardUnlinked = CardUnlinkedEvent(luid))) @@ -256,9 +336,15 @@ class Fedy( } } + private infix fun (() -> T).caught(onError: (FedyErr) -> T) = + try { this() } + catch (e: ApiException) { onError(FedyErr(code = e.code, message = e.message.toString())) } + companion object { const val KEY_HEADER = "X-Fedy-Key" + const val REQ_PART = "request" + const val PFP_PART = "profilePicture" val log = logger() fun getGameName(gameId: Str) = when (gameId) { diff --git a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt index 0570496e..9d84f5e2 100644 --- a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt +++ b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt @@ -12,6 +12,8 @@ import icu.samnyan.aqua.sega.general.model.CardStatus import icu.samnyan.aqua.sega.general.service.CardService import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile @@ -31,11 +33,11 @@ class UserRegistrar( val confirmationRepo: EmailConfirmationRepo, val resetPasswordRepo: ResetPasswordRepo, val cardRepo: CardRepository, - val cardService: CardService, val validator: AquaUserServices, val emailProps: EmailProperties, final val paths: PathProps ) { + @Autowired @Lazy lateinit var fedy: Fedy val portraitPath = paths.aquaNetPortrait.path() companion object { @@ -177,7 +179,9 @@ class UserRegistrar( if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired" // Change the password - async { userRepo.save(reset.aquaNetUser.apply { pwHash = validator.checkPwHash(password) }) } + val u = reset.aquaNetUser + async { userRepo.save(u.apply { pwHash = validator.checkPwHash(password) }) } + fedy.onUserUpdated(u) // Remove the token from the list resetPasswordRepo.delete(reset) @@ -199,8 +203,13 @@ class UserRegistrar( // Check if the token is expired if (confirmation.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired" + // Check if the email is already confirmed + val u = confirmation.aquaNetUser + if (u.emailConfirmed) 400 - "Email already confirmed" + // Confirm the email - async { userRepo.save(confirmation.aquaNetUser.apply { emailConfirmed = true }) } + async { userRepo.save(u.apply { emailConfirmed = true }) } + fedy.onUserUpdated(u, isNew = true) return SUCCESS } @@ -226,6 +235,7 @@ class UserRegistrar( // Clear all tokens if changing password if (key == "pwHash") validator.clearAllSessions(u) } + fedy.onUserUpdated(u) SUCCESS } @@ -264,6 +274,7 @@ class UserRegistrar( (portraitPath / name).writeBytes(bytes) userRepo.save(u.apply { profilePicture = name }) } + fedy.onUserUpdated(u) SUCCESS } diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt index 49727b0c..981d5e37 100644 --- a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt +++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt @@ -142,13 +142,14 @@ class AquaUserServices( } } - fun create(username: Str, email: Str, password: Str, country: Str): AquaNetUser { + fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser { // Create user val u = AquaNetUser( username = checkUsername(username), email = validateEmail(email), pwHash = checkPwHash(password), regTime = millis(), lastLogin = millis(), country = country, + emailConfirmed = emailConfirmed ) // Create a ghost card