feat: user management APIs (#184)

This commit is contained in:
Menci 2025-10-08 04:21:01 +08:00 committed by GitHub
parent a33ec8b11c
commit 3e6c0b4159
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 118 additions and 19 deletions

View File

@ -40,6 +40,7 @@ import kotlin.reflect.jvm.jvmErasure
typealias RP = RequestParam typealias RP = RequestParam
typealias RB = RequestBody typealias RB = RequestBody
typealias RT = RequestPart
typealias RH = RequestHeader typealias RH = RequestHeader
typealias PV = PathVariable typealias PV = PathVariable
typealias API = RequestMapping typealias API = RequestMapping

View File

@ -1,12 +1,14 @@
package icu.samnyan.aqua.net package icu.samnyan.aqua.net
import ext.* import ext.*
import icu.samnyan.aqua.net.components.EmailProperties
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.security.MessageDigest import java.security.MessageDigest
import icu.samnyan.aqua.net.utils.SUCCESS import icu.samnyan.aqua.net.utils.SUCCESS
import icu.samnyan.aqua.net.components.JWT 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.db.AquaUserServices
import icu.samnyan.aqua.net.games.mai2.Mai2Import import icu.samnyan.aqua.net.games.mai2.Mai2Import
import icu.samnyan.aqua.net.games.ExportOptions 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.sega.maimai2.model.Mai2UserDataRepo
import icu.samnyan.aqua.net.games.GenericUserDataRepo import icu.samnyan.aqua.net.games.GenericUserDataRepo
import icu.samnyan.aqua.net.games.IUserData 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.chusan.model.Chu3UserDataRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.general.service.CardService
import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo import icu.samnyan.aqua.sega.ongeki.OgkUserDataRepo
import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo import icu.samnyan.aqua.sega.wacca.model.db.WcUserRepo
import org.springframework.web.multipart.MultipartFile
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.isRegularFile
import kotlin.io.path.writeBytes
@Configuration @Configuration
@ConfigurationProperties(prefix = "aqua-net.fedy") @ConfigurationProperties(prefix = "aqua-net.fedy")
@ -34,12 +41,21 @@ class FedyProps {
var remote: String = "" 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 CardCreatedEvent(val luid: Str, val extId: Long)
private data class CardLinkedEvent(val luid: Str, val oldExtId: Long?, val ghostExtId: Long, val migratedGames: List<Str>) private data class CardLinkedEvent(val luid: Str, val oldExtId: Long?, val ghostExtId: Long, val migratedGames: List<Str>)
private data class CardUnlinkedEvent(val luid: Str) 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 DataUpdatedEvent(val extId: Long, val isGhostCard: Bool, val game: Str, val removeOldData: Bool)
private data class FedyEvent( private data class FedyEvent(
var userUpdated: UserUpdatedEvent? = null,
var cardCreated: CardCreatedEvent? = null, var cardCreated: CardCreatedEvent? = null,
var cardLinked: CardLinkedEvent? = null, var cardLinked: CardLinkedEvent? = null,
var cardUnlinked: CardUnlinkedEvent? = null, var cardUnlinked: CardUnlinkedEvent? = null,
@ -47,10 +63,11 @@ private data class FedyEvent(
) )
@RestController @RestController
@API("/api/v2/fedy") @API("/api/v2/fedy", consumes = ["multipart/form-data"])
class Fedy( class Fedy(
val jwt: JWT, val jwt: JWT,
val us: AquaUserServices, val us: AquaUserServices,
val emailProps: EmailProperties,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val cardService: CardService, val cardService: CardService,
val mai2Import: Mai2Import, val mai2Import: Mai2Import,
@ -61,6 +78,7 @@ class Fedy(
val ongekiUserDataRepo: OgkUserDataRepo, val ongekiUserDataRepo: OgkUserDataRepo,
val waccaUserDataRepo: WcUserRepo, val waccaUserDataRepo: WcUserRepo,
val props: FedyProps, val props: FedyProps,
val paths: PathProps,
val transactionManager: PlatformTransactionManager val transactionManager: PlatformTransactionManager
) { ) {
val transaction by lazy { TransactionTemplate(transactionManager) } val transaction by lazy { TransactionTemplate(transactionManager) }
@ -80,26 +98,87 @@ class Fedy(
} finally { suppressEvents.set(old) } } 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<Str, Str?>?)
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 DataPullReq(val extId: Long, val game: Str, val exportOptions: ExportOptions)
data class DataPullRes(val error: DataPullErr? = null, val result: Any? = null) data class DataPullRes(val error: FedyErr? = null, val result: Any? = null)
data class DataPullErr(val code: Int, val message: Str)
@API("/data/pull") @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) val card = cardRepo.findByExtId(req.extId).orElse(null)
?: (404 - "Card with extId ${req.extId} not found") ?: (404 - "Card with extId ${req.extId} not found")
fun caught(block: () -> Any) = {
try { DataPullRes(result = block()) } DataPullRes(result = when (req.game) {
catch (e: ApiException) { DataPullRes(error = DataPullErr(code = e.code, message = e.message.toString())) } "mai2" -> mai2Import.export(card, req.exportOptions)
when (req.game) { else -> 406 - "Unsupported game"
"mai2" -> caught { 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) data class DataPushReq(val extId: Long, val game: Str, val data: JDict, val removeOldData: Bool)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@API("/data/push") @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 val extId = req.extId
fun<UserData : IUserData, UserRepo : GenericUserDataRepo<UserData>> removeOldData(repo: UserRepo) { fun<UserData : IUserData, UserRepo : GenericUserDataRepo<UserData>> removeOldData(repo: UserRepo) {
val oldData = repo.findByCard_ExtId(extId) 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 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) data class CardResolveRes(val extId: Long, val isGhostCard: Bool, val isNewlyCreated: Bool, val isPairedLuidDiverged: Bool)
@API("/card/resolve") @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 card = cardService.tryLookup(req.luid)
var isNewlyCreated = false var isNewlyCreated = false
if (card != null) { if (card != null) {
@ -163,7 +242,7 @@ class Fedy(
data class CardLinkReq(val auId: Long, val luid: Str) data class CardLinkReq(val auId: Long, val luid: Str)
@API("/card/link") @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") val ru = us.userRepo.findByAuId(req.auId) ?: (404 - "User not found")
var card = cardService.tryLookup(req.luid) var card = cardService.tryLookup(req.luid)
if (card == null) { if (card == null) {
@ -185,7 +264,7 @@ class Fedy(
data class CardUnlinkReq(val auId: Long, val luid: Str) data class CardUnlinkReq(val auId: Long, val luid: Str)
@API("/card/unlink") @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 card = cardService.tryLookup(req.luid)
val cu = card?.aquaUser ?: return@handleFedy SUCCESS // Nothing to do 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})") 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 onCardCreated(luid: Str, extId: Long) = maybeNotifyAsync(FedyEvent(cardCreated = CardCreatedEvent(luid, extId)))
fun onCardLinked(luid: Str, oldExtId: Long?, ghostExtId: Long, migratedGames: List<Str>) = maybeNotifyAsync(FedyEvent(cardLinked = CardLinkedEvent(luid, oldExtId, ghostExtId, migratedGames))) fun onCardLinked(luid: Str, oldExtId: Long?, ghostExtId: Long, migratedGames: List<Str>) = maybeNotifyAsync(FedyEvent(cardLinked = CardLinkedEvent(luid, oldExtId, ghostExtId, migratedGames)))
fun onCardUnlinked(luid: Str) = maybeNotifyAsync(FedyEvent(cardUnlinked = CardUnlinkedEvent(luid))) fun onCardUnlinked(luid: Str) = maybeNotifyAsync(FedyEvent(cardUnlinked = CardUnlinkedEvent(luid)))
@ -256,9 +336,15 @@ class Fedy(
} }
} }
private infix fun <T> (() -> T).caught(onError: (FedyErr) -> T) =
try { this() }
catch (e: ApiException) { onError(FedyErr(code = e.code, message = e.message.toString())) }
companion object companion object
{ {
const val KEY_HEADER = "X-Fedy-Key" const val KEY_HEADER = "X-Fedy-Key"
const val REQ_PART = "request"
const val PFP_PART = "profilePicture"
val log = logger() val log = logger()
fun getGameName(gameId: Str) = when (gameId) { fun getGameName(gameId: Str) = when (gameId) {

View File

@ -12,6 +12,8 @@ import icu.samnyan.aqua.sega.general.model.CardStatus
import icu.samnyan.aqua.sega.general.service.CardService import icu.samnyan.aqua.sega.general.service.CardService
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory 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.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@ -31,11 +33,11 @@ class UserRegistrar(
val confirmationRepo: EmailConfirmationRepo, val confirmationRepo: EmailConfirmationRepo,
val resetPasswordRepo: ResetPasswordRepo, val resetPasswordRepo: ResetPasswordRepo,
val cardRepo: CardRepository, val cardRepo: CardRepository,
val cardService: CardService,
val validator: AquaUserServices, val validator: AquaUserServices,
val emailProps: EmailProperties, val emailProps: EmailProperties,
final val paths: PathProps final val paths: PathProps
) { ) {
@Autowired @Lazy lateinit var fedy: Fedy
val portraitPath = paths.aquaNetPortrait.path() val portraitPath = paths.aquaNetPortrait.path()
companion object { companion object {
@ -177,7 +179,9 @@ class UserRegistrar(
if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired" if (reset.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token expired"
// Change the password // 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 // Remove the token from the list
resetPasswordRepo.delete(reset) resetPasswordRepo.delete(reset)
@ -199,8 +203,13 @@ class UserRegistrar(
// Check if the token is expired // Check if the token is expired
if (confirmation.createdAt.plusSeconds(60 * 60 * 24).isBefore(Instant.now())) 400 - "Token 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 // 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 return SUCCESS
} }
@ -226,6 +235,7 @@ class UserRegistrar(
// Clear all tokens if changing password // Clear all tokens if changing password
if (key == "pwHash") validator.clearAllSessions(u) if (key == "pwHash") validator.clearAllSessions(u)
} }
fedy.onUserUpdated(u)
SUCCESS SUCCESS
} }
@ -264,6 +274,7 @@ class UserRegistrar(
(portraitPath / name).writeBytes(bytes) (portraitPath / name).writeBytes(bytes)
userRepo.save(u.apply { profilePicture = name }) userRepo.save(u.apply { profilePicture = name })
} }
fedy.onUserUpdated(u)
SUCCESS SUCCESS
} }

View File

@ -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 // Create user
val u = AquaNetUser( val u = AquaNetUser(
username = checkUsername(username), username = checkUsername(username),
email = validateEmail(email), email = validateEmail(email),
pwHash = checkPwHash(password), pwHash = checkPwHash(password),
regTime = millis(), lastLogin = millis(), country = country, regTime = millis(), lastLogin = millis(), country = country,
emailConfirmed = emailConfirmed
) )
// Create a ghost card // Create a ghost card