Compare commits

...

2 Commits

Author SHA1 Message Date
Menci
3e6c0b4159
feat: user management APIs (#184) 2025-10-07 13:21:01 -07:00
Menci
a33ec8b11c
feat: crop pfp to at most 1024px (#183) 2025-10-07 13:20:49 -07:00
5 changed files with 123 additions and 23 deletions

View File

@ -80,11 +80,12 @@
// Don't know why this isn't just a part of the cropper module. Have to do this myself.. What a shame
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
canvas.width = 256;
canvas.height = 256;
const size = Math.round(Math.min(pfpCrop.width, pfpCrop.height, 1024));
canvas.width = size;
canvas.height = size;
let img = document.createElement("img");
img.onload = () => {
ctx?.drawImage(img, pfpCrop.x, pfpCrop.y, pfpCrop.width, pfpCrop.height, 0, 0, 256, 256);
ctx?.drawImage(img, pfpCrop.x, pfpCrop.y, pfpCrop.width, pfpCrop.height, 0, 0, size, size);
canvas.toBlob(blob => {
if (!blob) return;
submitting = 'profilePicture'
@ -282,7 +283,7 @@
object-fit: cover
aspect-ratio: 1
.cropper-container
position: relative

View File

@ -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

View File

@ -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<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 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<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 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<UserData : IUserData, UserRepo : GenericUserDataRepo<UserData>> 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<Str>) = 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> (() -> 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) {

View File

@ -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
}

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