diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml index 336652879..ab0e179e3 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog-generation.xml @@ -2470,4 +2470,9 @@ Indexes for latest move analysis aggregation and analyzed flag lookup + + + + + diff --git a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml index ac7f77cbf..57a83e3ff 100644 --- a/webapp-dao-migration/src/main/resources/liquibase-changelog.xml +++ b/webapp-dao-migration/src/main/resources/liquibase-changelog.xml @@ -2697,4 +2697,9 @@ ON bot_game (analysis_end_time, analyzed_from_batch); + + + + + diff --git a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt index 6c9f455b0..99d6d1b01 100644 --- a/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt +++ b/webapp-dao/src/main/kotlin/io/elephantchess/db/services/UserDaoService.kt @@ -17,6 +17,7 @@ import io.github.oshai.kotlinlogging.KLogger import io.elephantchess.xiangqi.Variant import org.jooq.DSLContext import org.jooq.Record2 +import org.jooq.Record3 import org.jooq.TableField import org.jooq.impl.DSL import org.jooq.kotlin.coroutines.transactionCoroutine @@ -28,6 +29,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { + private val profilePictureExtensionField = DSL.field(DSL.name("profile_picture_extension"), String::class.java) suspend fun save(user: User): String { dslContext.transactionCoroutine { cfg -> @@ -80,11 +82,12 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { return id!! } - suspend fun fetchProfileSettings(userId: String): Record2? { + suspend fun fetchProfileSettings(userId: String): Record3? { return dslContext .select( USER.DESCRIPTION, - USER.COUNTRY + USER.COUNTRY, + profilePictureExtensionField ) .from(USER) .where(USER.ID.eq(userId)) @@ -105,6 +108,33 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) { } } + suspend fun updateProfilePictureExtension(userId: String, extension: String) { + dslContext.transactionCoroutine { cfg -> + DSL + .using(cfg) + .update(USER.fixed()) + .set(profilePictureExtensionField, extension) + .set(USER.LAST_PROFILE_UPDATE.fixed(), Clock.System.now()) + .where(USER.ID.fixed().eq(userId)) + .awaitExecute() + } + } + + suspend fun fetchPublicProfile(username: String): User? { + return dslContext + .select( + USER.ID, + USER.HANDLE, + USER.COUNTRY, + USER.DESCRIPTION, + USER.PUZZLE_RATING, + profilePictureExtensionField + ) + .from(USER) + .where(USER.HANDLE.eqIgnoreCaseTrimmed(username)) + .awaitSingleMappedRecord() + } + suspend fun fetchNotificationSettings(userId: String): NotificationsSettingsRecord? { return dslContext .select( diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt index baa2cbdff..de44a1034 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/KoinModule.kt @@ -182,6 +182,7 @@ private fun applicativeModule(eagerAllowed: Boolean) = module { // users singleAuto() singleAuto(eager = eagerAllowed) + singleAuto() singleAuto() singleAuto(eager = eagerAllowed) singleAuto() diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/clients/DigitalOceanSpacesClient.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/clients/DigitalOceanSpacesClient.kt index 835a118f6..6ad6a9091 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/clients/DigitalOceanSpacesClient.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/clients/DigitalOceanSpacesClient.kt @@ -23,7 +23,7 @@ class DigitalOceanSpacesClient( private val secretAccessKey by lazy { appConfig.doSpacesKeySecret } private val bucketName by lazy { appConfig.doSpacesBucket } private val region = "ams3" - private val endpoint = "$bucketName.$region.digitaloceanspaces.com" + private val endpoint by lazy { "$bucketName.$region.digitaloceanspaces.com" } private val client = HttpClient(CIO) { install(Logging) { diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfilePictureUploadResponse.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfilePictureUploadResponse.kt new file mode 100644 index 000000000..429c284ff --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfilePictureUploadResponse.kt @@ -0,0 +1,5 @@ +package io.elephantchess.servicelayer.dto.user + +data class ProfilePictureUploadResponse( + val profilePictureUrl: String +) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfileSettingsDto.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfileSettingsDto.kt index 689cc1837..2a9ea7610 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfileSettingsDto.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/ProfileSettingsDto.kt @@ -2,5 +2,6 @@ package io.elephantchess.servicelayer.dto.user data class ProfileSettingsDto( val description: String, - val country: String + val country: String, + val profilePictureUrl: String? = null, ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserProfile.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserProfile.kt index b63f48c89..479497c38 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserProfile.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/dto/user/UserProfile.kt @@ -6,4 +6,5 @@ data class UserProfile( val country: String?, val profileDescription: String?, val puzzleRating: Int, + val profilePictureUrl: String? = null, ) diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureService.kt new file mode 100644 index 000000000..b5c47c04f --- /dev/null +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureService.kt @@ -0,0 +1,153 @@ +package io.elephantchess.servicelayer.services + +import io.elephantchess.config.AppConfig +import io.elephantchess.db.services.UserDaoService +import io.elephantchess.servicelayer.clients.DigitalOceanSpacesClient +import io.elephantchess.servicelayer.exceptions.NotAcceptableException +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +class UserProfilePictureService( + appConfig: AppConfig, + private val spacesClient: DigitalOceanSpacesClient, + private val userDaoService: UserDaoService, +) { + + private val profile = requireValidProfileSegment(appConfig.profile) + + private fun profilePictureKey(userId: String, extension: String): String { + return "$profile/$PROFILE_PICTURE_FOLDER/$userId.$extension" + } + + /** + * Validate and normalize a profile picture upload, store it on the CDN-backed object storage, + * persist the chosen file extension for the user, and return the public CDN URL. + * + * @throws NotAcceptableException when the file is too large, has an unsupported extension, + * or cannot be decoded into a supported image format. + */ + suspend fun uploadProfilePicture(userId: String, originalFileName: String, bytes: ByteArray): String { + if (bytes.size > PROFILE_PICTURE_MAX_BYTES) { + throw NotAcceptableException("Profile picture limited to ${PROFILE_PICTURE_MAX_BYTES / 1024}KB") + } + + val extension = requireSupportedExtension(originalFileName) + val normalizedBytes = normalizeProfilePicture(bytes, extension) + if (normalizedBytes.size > PROFILE_PICTURE_MAX_BYTES) { + throw NotAcceptableException("Profile picture limited to ${PROFILE_PICTURE_MAX_BYTES / 1024}KB") + } + + val key = profilePictureKey(userId, extension) + val uploaded = spacesClient.uploadBytes( + bytes = normalizedBytes, + key = key, + contentType = contentTypeFor(extension), + acl = "public-read", + ) + + if (!uploaded) { + throw IllegalStateException("Unable to upload profile picture") + } + + userDaoService.updateProfilePictureExtension(userId, extension) + return profilePictureUrl(profile, userId, extension)!! + } + + internal fun normalizeProfilePicture(bytes: ByteArray, extension: String): ByteArray { + val sourceImage = ImageIO.read(ByteArrayInputStream(bytes)) + ?: throw NotAcceptableException("Unable to read image file - the file may be corrupted or in an unsupported format") + + val size = minOf(sourceImage.width, sourceImage.height) + val cropX = (sourceImage.width - size) / 2 + val cropY = (sourceImage.height - size) / 2 + val isJpeg = isJpegExtension(extension) + val outputImage = BufferedImage( + PROFILE_PICTURE_SIZE_PX, + PROFILE_PICTURE_SIZE_PX, + if (isJpeg) BufferedImage.TYPE_INT_RGB else BufferedImage.TYPE_INT_ARGB + ) + + val graphics = outputImage.createGraphics() + try { + if (isJpeg) { + graphics.color = Color.WHITE + graphics.fillRect(0, 0, PROFILE_PICTURE_SIZE_PX, PROFILE_PICTURE_SIZE_PX) + } + graphics.drawImage( + sourceImage, + 0, + 0, + PROFILE_PICTURE_SIZE_PX, + PROFILE_PICTURE_SIZE_PX, + cropX, + cropY, + cropX + size, + cropY + size, + null + ) + } finally { + graphics.dispose() + } + + val output = ByteArrayOutputStream() + if (!ImageIO.write(outputImage, imageIoFormatName(extension), output)) { + throw NotAcceptableException("Unable to process image file for profile picture upload") + } + + return output.toByteArray() + } + + private fun requireSupportedExtension(originalFileName: String): String { + val extension = originalFileName.substringAfterLast('.', "").lowercase() + if (extension !in SUPPORTED_EXTENSIONS) { + throw NotAcceptableException("Only PNG and JPEG profile pictures are supported") + } + return extension + } + + private fun contentTypeFor(extension: String): String { + return when (extension) { + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + else -> error("Unsupported file extension for profile picture: $extension") + } + } + + private fun imageIoFormatName(extension: String): String { + return if (extension == "jpg") "jpeg" else extension + } + + private fun isJpegExtension(extension: String): Boolean { + return extension == "jpg" || extension == "jpeg" + } + + companion object { + const val PROFILE_PICTURE_FOLDER = "profile-pictures" + const val PROFILE_PICTURE_SIZE_PX = 100 + const val PROFILE_PICTURE_MAX_BYTES = 500 * 1024 + private const val CDN_BASE = "https://cdn.elephantchess.io" + private val VALID_PROFILE_SEGMENT_REGEX = Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$") + private val SUPPORTED_EXTENSIONS = setOf("png", "jpg", "jpeg") + + private fun requireValidProfileSegment(profile: String): String { + require(profile.matches(VALID_PROFILE_SEGMENT_REGEX)) { + "Unsupported profile segment for profile pictures: $profile" + } + return profile + } + + fun profilePictureKey(profile: String, userId: String, extension: String): String { + val sanitizedProfile = requireValidProfileSegment(profile) + return "$sanitizedProfile/$PROFILE_PICTURE_FOLDER/$userId.$extension" + } + + fun profilePictureUrl(profile: String, userId: String, extension: String?): String? { + val sanitizedExtension = extension?.lowercase()?.takeIf { it in SUPPORTED_EXTENSIONS } ?: return null + val sanitizedProfile = requireValidProfileSegment(profile) + return "$CDN_BASE/$sanitizedProfile/$PROFILE_PICTURE_FOLDER/$userId.$sanitizedExtension" + } + } +} diff --git a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt index d1b45ff87..d51ff9330 100644 --- a/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt +++ b/webapp-service-layer/src/main/kotlin/io/elephantchess/servicelayer/services/UserService.kt @@ -41,6 +41,7 @@ class UserService( private val userSessionService: UserSessionService, private val tokenManager: TokenManager, private val mailService: MailService, + private val userProfilePictureService: UserProfilePictureService, private val pageViewEventService: PageViewEventService, private val settingPreferenceEventService: SettingPreferenceEventService, refresherScope: CoroutineScope, @@ -59,6 +60,7 @@ class UserService( // password hashing private val salt: ByteArray = appConfig.salt.toByteArray() + private val profile = appConfig.profile private val secretKeyFactory = SecretKeyFactory.getInstance(SALT_ALGO) private val refreshJob = launchAtFixedRateStartImmediately( @@ -337,17 +339,17 @@ class UserService( } suspend fun fetchProfile(username: String): UserProfile { - // TODO: only fetch relevant fields - val user = userDaoService.findByUserName(username) - return if (user == null) { + val record = userDaoService.fetchPublicProfile(username) + return if (record == null) { throw NotFoundException("User $username could not be found") } else { UserProfile( - userId = user.id, - username = user.handle, - country = normalizeCountry(user.country), - profileDescription = user.description, - puzzleRating = user.puzzleRating + userId = record.id, + username = record.handle, + country = normalizeCountry(record.country), + profileDescription = record.description, + puzzleRating = record.puzzleRating, + profilePictureUrl = UserProfilePictureService.profilePictureUrl(profile, record.id, record.profilePictureExtension) ) } } @@ -357,7 +359,8 @@ class UserService( if (record != null) { return ProfileSettingsDto( description = record.value1().orEmpty(), - country = record.value2().orEmpty() + country = record.value2().orEmpty(), + profilePictureUrl = UserProfilePictureService.profilePictureUrl(profile, userId, record.value3()) ) } else { throw NotFoundException("User not found") @@ -380,6 +383,11 @@ class UserService( userDaoService.updateProfileSettings(userId, description, country) } + suspend fun uploadProfilePicture(userId: String, originalFileName: String, bytes: ByteArray): ProfilePictureUploadResponse { + val url = userProfilePictureService.uploadProfilePicture(userId, originalFileName, bytes) + return ProfilePictureUploadResponse(url) + } + suspend fun fetchNotificationsSettings(userId: String): NotificationsSettingsDto { val record = userDaoService.fetchNotificationSettings(userId) ?: throw NotFoundException("User not found") diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt index 36297a999..6d671589c 100644 --- a/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/db/services/UserDaoServiceTest.kt @@ -15,6 +15,8 @@ import org.koin.core.component.inject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class UserDaoServiceTest : ServiceTest() { @@ -52,6 +54,24 @@ class UserDaoServiceTest : ServiceTest() { assertUnsubscribedToAll(email) } + @Test + fun `fetchPublicProfile should map to user pojo`() = runTest { + val (request, userId) = signUpTestUser() + + userDaoService.updateProfileSettings(userId, "about me", "be") + userDaoService.updateProfilePictureExtension(userId, "png") + + val user = assertNotNull(userDaoService.fetchPublicProfile(request.username)) + + assertEquals(userId, user.id) + assertEquals(request.username, user.handle) + assertEquals("be", user.country) + assertEquals("about me", user.description) + assertEquals(800, user.puzzleRating) + assertEquals("png", user.profilePictureExtension) + assertNull(user.email) + } + @Test fun `fetchRatingSummary supports guest and authenticated filters`() = runTest { val baseline = userDaoService.fetchRatingSummary(BULLET, XIANGQI) diff --git a/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureServiceTest.kt b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureServiceTest.kt new file mode 100644 index 000000000..de7d590d3 --- /dev/null +++ b/webapp-service-layer/src/test/kotlin/io/elephantchess/servicelayer/services/UserProfilePictureServiceTest.kt @@ -0,0 +1,111 @@ +package io.elephantchess.servicelayer.services + +import io.elephantchess.config.AppConfig +import io.elephantchess.config.DbConfig +import io.elephantchess.config.PropertiesFile +import io.elephantchess.db.services.UserDaoService +import io.elephantchess.servicelayer.clients.DigitalOceanSpacesClient +import kotlinx.coroutines.test.runTest +import org.mockito.kotlin.any +import org.mockito.kotlin.check +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UserProfilePictureServiceTest { + + private val spacesClient = mock() + private val userDaoService = mock() + private val propertiesFile = mock() + private fun testAppConfig(profile: String) = AppConfig( + profile = profile, + webHost = "localhost", + isMinificationEnabled = false, + isGoogleAnalyticsEnabled = false, + isCookieConsentBannerEnabled = false, + isDockerized = false, + sendMailNotifications = false, + isCachingEnabled = false, + isEnginePoolEnabled = false, + enginesThreads = 1, + pikafishVersion = "", + fairyStockfishVersion = "", + dbConfig = DbConfig("test", "jdbc:postgresql://localhost/test", "postgres", "postgres"), + parseUserAgent = false, + disabledBatches = emptyList(), + cdnEnabled = true, + symmetricKey = "", + salt = "", + properties = propertiesFile, + ) + + private val service = UserProfilePictureService(testAppConfig("local-backup"), spacesClient, userDaoService) + + @Test + fun `profilePictureUrl returns null when extension is missing`() { + assertNull(UserProfilePictureService.profilePictureUrl("local-backup", "user-1", null)) + } + + @Test + fun `invalid profile segment is rejected`() { + assertFailsWith { + UserProfilePictureService(testAppConfig("../prod"), spacesClient, userDaoService) + } + } + + @Test + fun `normalizeProfilePicture crops image to 100 by 100`() { + val source = BufferedImage(200, 100, BufferedImage.TYPE_INT_RGB) + for (x in 0 until source.width) { + for (y in 0 until source.height) { + source.setRGB(x, y, if (x < 100) Color.RED.rgb else Color.BLUE.rgb) + } + } + + val output = ByteArrayOutputStream() + ImageIO.write(source, "png", output) + + val normalized = service.normalizeProfilePicture(output.toByteArray(), "png") + val image = ImageIO.read(ByteArrayInputStream(normalized)) + + assertEquals(100, image.width) + assertEquals(100, image.height) + assertTrue(Color(image.getRGB(10, 50)).red > Color(image.getRGB(10, 50)).blue) + assertTrue(Color(image.getRGB(90, 50)).blue > Color(image.getRGB(90, 50)).red) + } + + @Test + fun `uploadProfilePicture uploads normalized picture and persists extension`() = runTest { + whenever(spacesClient.uploadBytes(any(), any(), any(), any())).thenReturn(true) + + val source = BufferedImage(140, 90, BufferedImage.TYPE_INT_RGB) + val output = ByteArrayOutputStream() + ImageIO.write(source, "png", output) + + val url = service.uploadProfilePicture("user-1", "photo.png", output.toByteArray()) + + assertEquals("https://cdn.elephantchess.io/local-backup/profile-pictures/user-1.png", url) + verify(userDaoService).updateProfilePictureExtension("user-1", "png") + verify(spacesClient).uploadBytes( + check { bytes -> + val image = ImageIO.read(ByteArrayInputStream(bytes)) + assertEquals(100, image.width) + assertEquals(100, image.height) + }, + eq("local-backup/profile-pictures/user-1.png"), + eq("image/png"), + eq("public-read"), + ) + } +} diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt index 094d22fbe..2a0fc51bd 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRenderer.kt @@ -38,6 +38,7 @@ class UserProfilePageRenderer( SimpleValueTagResolver("user_id", userProfile.userId), SimpleValueTagResolver("username", userProfile.username), descriptionMeta(username, description), + profilePictureTagResolver(userProfile.profilePictureUrl, username), flagPanelTagResolver(countryCode), descriptionDivTagResolver(username, description), gameStatsTableTagResolver(gameStats), @@ -80,6 +81,25 @@ class UserProfilePageRenderer( } } + private fun profilePictureTagResolver(profilePictureUrl: String?, username: String): TagResolver { + return CallbackTagResolver("profile_picture_panel") { + if (profilePictureUrl != null) { + val escapedUsername = escapeHtml(username) + """
+ |$escapedUsername profile picture + |
""".trimMargin() + } else { + "" + } + } + } + + private fun escapeHtml(s: String): String = + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + private fun descriptionDivTagResolver(username: String, description: String?): TagResolver { return KtorHtmlBuilderTagResolver("user_profile_description") { if (!description.isNullOrBlank()) { diff --git a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt index 711409ecd..060614fcf 100644 --- a/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt +++ b/webapp/src/main/kotlin/io/elephantchess/webapp/routing/api/UserRouting.kt @@ -3,6 +3,7 @@ package io.elephantchess.webapp.routing.api import io.elephantchess.servicelayer.dto.ContactFormRequest import io.elephantchess.servicelayer.dto.ContentSectionVoteRequest import io.elephantchess.servicelayer.dto.user.* +import io.elephantchess.servicelayer.exceptions.NotAcceptableException import io.elephantchess.servicelayer.model.GuestToken import io.elephantchess.servicelayer.services.ContentSectionFeedbackService import io.elephantchess.servicelayer.services.GlobalAnalyticsService @@ -12,11 +13,14 @@ import io.elephantchess.servicelayer.services.UserService import io.elephantchess.servicelayer.utils.ops.koin import io.elephantchess.webapp.ops.* import io.ktor.http.HttpStatusCode.Companion.Created +import io.ktor.http.content.PartData +import io.ktor.server.application.ApplicationCall import io.ktor.server.plugins.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.util.* +import io.ktor.utils.io.* private val userService by koin() private val userProfileAnalyticsService by koin() @@ -140,6 +144,12 @@ private fun Route.userSettingsRoutes() { userService.updateProfileSettings(verifiedToken.userId, request) } } + post("/profile-picture") { + requireAuthentication { verifiedToken -> + val upload = call.receiveProfilePictureUpload() + userService.uploadProfilePicture(verifiedToken.userId, upload.fileName, upload.bytes) + } + } get("/notifications") { requireAuthentication { verifiedToken -> userService.fetchNotificationsSettings(verifiedToken.userId) @@ -189,6 +199,30 @@ private fun Route.userSettingsRoutes() { } } +private data class ProfilePictureUpload( + val fileName: String, + val bytes: ByteArray, +) + +private suspend fun ApplicationCall.receiveProfilePictureUpload(): ProfilePictureUpload { + var upload: ProfilePictureUpload? = null + val multipart = receiveMultipart() + + while (true) { + val part = multipart.readPart() ?: break + try { + if (part is PartData.FileItem && upload == null) { + val fileName = part.originalFileName ?: throw NotAcceptableException("Missing profile picture file name") + upload = ProfilePictureUpload(fileName, part.provider().toByteArray()) + } + } finally { + part.dispose() + } + } + + return upload ?: throw NotAcceptableException("Missing profile picture file") +} + private fun Route.passwordRecoveryRoutes() { route("/api/user/password/recovery") { post("/attempt") { diff --git a/webapp/src/main/resources/public/css/user-profile.css b/webapp/src/main/resources/public/css/user-profile.css index 474fdcb30..1d733000a 100644 --- a/webapp/src/main/resources/public/css/user-profile.css +++ b/webapp/src/main/resources/public/css/user-profile.css @@ -52,7 +52,7 @@ h2 { display: flex; justify-content: left; align-items: center; - height: 40px; + min-height: 40px; } .profile-header-panel { @@ -61,9 +61,21 @@ h2 { margin-right: 10px; } +#profile-picture { + width: 100px; + height: 100px; + object-fit: cover; + border-radius: 12px; + border: 1px solid rgba(98, 98, 99, 0.25); +} + +.profile-picture-header-panel { + margin-right: 16px; +} + #profile-flag, #status-indicator { - margin-top: 13px; + margin-top: 0; } #profile-description { @@ -180,13 +192,19 @@ h2 { } #profile-header { - height: 62px; + min-height: 62px; } .profile-header-panel { margin-right: 14px; } + #profile-picture { + width: 150px; + height: 150px; + border-radius: 16px; + } + .flag-icons { height: 25px; } diff --git a/webapp/src/main/resources/public/css/user-settings.css b/webapp/src/main/resources/public/css/user-settings.css index cb1cc2c8f..42a03dc14 100644 --- a/webapp/src/main/resources/public/css/user-settings.css +++ b/webapp/src/main/resources/public/css/user-settings.css @@ -61,6 +61,38 @@ padding-right: 3px; } +#profile-picture-settings { + margin: 15px 0 15px 4px; +} + +#profile-picture-preview { + width: 100px; + height: 100px; + object-fit: cover; + border-radius: 12px; + border: 1px solid rgba(98, 98, 99, 0.25); + display: block; + margin-bottom: 14px; +} + +#profile-picture-editor { + margin-top: 14px; + max-width: 340px; +} + +#profile-picture-editor-canvas { + border: 1px solid rgba(98, 98, 99, 0.35); + border-radius: 12px; + cursor: grab; + display: block; + margin: 8px 0 10px; + max-width: 100%; +} + +.profile-picture-help { + font-size: 15px; + max-width: 500px; +} #sessions-actions-container { margin: 10px 0 12px 12px; } @@ -141,6 +173,19 @@ padding-right: 6px; } + #profile-picture-preview { + width: 150px; + height: 150px; + border-radius: 16px; + } + + #profile-picture-editor { + max-width: 90%; + } + + .profile-picture-help { + font-size: 28px; + } #sessions-actions-container { font-size: 30px; margin-left: 10px; @@ -151,7 +196,6 @@ margin-left: 0; margin-top: 10px; } - .session-os-icon { width: 34px; height: 34px; diff --git a/webapp/src/main/resources/public/js/modules/api.js b/webapp/src/main/resources/public/js/modules/api.js index 46a19bdb8..dcde80c4f 100644 --- a/webapp/src/main/resources/public/js/modules/api.js +++ b/webapp/src/main/resources/public/js/modules/api.js @@ -95,13 +95,16 @@ function getToken() { /** * @return {object} */ -function headers() { +function headers(includeJsonContentType = true) { const headers = { - 'Accept': 'application/json', - 'Content-Type': 'application/json' + 'Accept': 'application/json' }; + if (includeJsonContentType) { + headers['Content-Type'] = 'application/json'; + } + const token = getToken(); if (token != null) { headers.Authorization = `Bearer ${token}`; @@ -141,7 +144,11 @@ function postAndHandle(url, body, cb) { function postAndHandleWith(url, body, handler) { let init; if (body != null) { - init = {method: 'POST', headers: headers(), body: JSON.stringify(body)}; + if (body instanceof FormData) { + init = {method: 'POST', headers: headers(false), body: body}; + } else { + init = {method: 'POST', headers: headers(), body: JSON.stringify(body)}; + } } else { init = {method: 'POST', headers: headers()}; } diff --git a/webapp/src/main/resources/public/js/user-settings/user-settings.js b/webapp/src/main/resources/public/js/user-settings/user-settings.js index 5befc12cc..413d7df46 100644 --- a/webapp/src/main/resources/public/js/user-settings/user-settings.js +++ b/webapp/src/main/resources/public/js/user-settings/user-settings.js @@ -21,10 +21,17 @@ const UI_NOTIFICATION_TIMEOUT = 2_500; const USER_SETTINGS_API = '/api/user/settings'; const PROFILE_URL = USER_SETTINGS_API + '/profile'; +const PROFILE_PICTURE_UPLOAD_URL = USER_SETTINGS_API + '/profile-picture'; const EMAIL_SETTINGS_URL = USER_SETTINGS_API + '/email-address'; const RESEND_EMAIL_CONFIRMATION_URL = EMAIL_SETTINGS_URL + '/resend-confirmation'; const USERNAME_MAX_DESCRIPTION_LENGTH = 1_000; +// Keep in sync with UserProfilePictureService.PROFILE_PICTURE_MAX_BYTES on the backend. +const PROFILE_PICTURE_MAX_BYTES = 500 * 1024; +const PROFILE_PICTURE_SIZE_PX = 100; +// Keep JPEG uploads visually crisp while staying comfortably under the 500KB limit. +const PROFILE_PICTURE_JPEG_QUALITY = 0.92; +const DEFAULT_PROFILE_PICTURE_URL = '/images/icons/user_profile_smaller.png'; class UserSettingsPage extends BasePage { @@ -33,6 +40,13 @@ class UserSettingsPage extends BasePage { #descriptionField = document.getElementById('description'); #countryField = document.getElementById('countries'); #descriptionCharacterCounter = document.getElementById('description-character-counter'); + #profilePicturePreview = document.getElementById('profile-picture-preview'); + #profilePictureInput = document.getElementById('profile-picture-input'); + #selectProfilePictureButton = document.getElementById('select-profile-picture-button'); + #profilePictureEditor = document.getElementById('profile-picture-editor'); + #profilePictureEditorCanvas = document.getElementById('profile-picture-editor-canvas'); + #profilePictureZoomField = document.getElementById('profile-picture-zoom'); + #uploadProfilePictureButton = document.getElementById('upload-profile-picture-button'); // email notifications section #notificationSettingsWidget = new NotificationSettingsWidget(); @@ -41,6 +55,14 @@ class UserSettingsPage extends BasePage { // TODO: email address section #emailAddressField = document.getElementById('email-address'); + #profilePictureImage = null; + #profilePictureCrop = null; + #profilePictureObjectUrl = null; + #profilePictureMimeType = 'image/png'; + #profilePictureExtension = 'png'; + #isDraggingProfilePicture = false; + #profilePictureDragOffsetX = 0; + #profilePictureDragOffsetY = 0; // sessions section #sessionsWidget = new UserSessionsWidget({limit: 8, selectable: false}); @@ -53,6 +75,15 @@ class UserSettingsPage extends BasePage { this.#saveProfileButton.addEventListener('click', () => this.#updateProfileSettings()); this.#descriptionField.addEventListener('input', () => this.#updateDescriptionCharacterCounter()); this.#descriptionField.setAttribute('maxlength', USERNAME_MAX_DESCRIPTION_LENGTH.toString()); + this.#selectProfilePictureButton.addEventListener('click', () => this.#profilePictureInput.click()); + this.#profilePictureInput.addEventListener('change', () => this.#loadProfilePictureSelection()); + this.#profilePictureZoomField.addEventListener('input', () => this.#updateProfilePictureCropFromZoom()); + this.#uploadProfilePictureButton.addEventListener('click', () => this.#uploadProfilePicture()); + this.#profilePictureEditorCanvas.addEventListener('pointerdown', event => this.#startDraggingProfilePicture(event)); + this.#profilePictureEditorCanvas.addEventListener('pointermove', event => this.#dragProfilePicture(event)); + this.#profilePictureEditorCanvas.addEventListener('pointerup', event => this.#stopDraggingProfilePicture(event)); + this.#profilePictureEditorCanvas.addEventListener('pointerleave', event => this.#stopDraggingProfilePicture(event)); + window.addEventListener('beforeunload', () => this.#revokeProfilePictureObjectUrl()); // notifications section let notificationsSettingsTable = document.getElementById('notifications-settings-table'); @@ -74,6 +105,7 @@ class UserSettingsPage extends BasePage { getAndHandle(PROFILE_URL, json => { this.#descriptionField.value = json.description ?? ''; this.#updateDescriptionCharacterCounter(); + this.#setProfilePicturePreview(json.profilePictureUrl); if (json.country != null) { let countryName = getCountryName(json.country) if (countryName != null) { @@ -107,6 +139,227 @@ class UserSettingsPage extends BasePage { this.#descriptionCharacterCounter.innerText = `${this.#descriptionField.value.length} / ${USERNAME_MAX_DESCRIPTION_LENGTH}`; } + #setProfilePicturePreview(url) { + this.#profilePicturePreview.src = url ?? DEFAULT_PROFILE_PICTURE_URL; + } + + #loadProfilePictureSelection() { + const file = this.#profilePictureInput.files?.[0]; + if (file == null) { + return; + } + + const fileFormat = this.#resolveProfilePictureFormat(file.name); + if (fileFormat == null) { + UI.pushErrorNotification('Only PNG and JPEG profile pictures are supported', 3_000); + return; + } + + this.#revokeProfilePictureObjectUrl(); + + this.#profilePictureExtension = fileFormat.extension; + this.#profilePictureMimeType = fileFormat.mimeType; + this.#profilePictureObjectUrl = URL.createObjectURL(file); + + const image = new Image(); + image.onload = () => { + const size = Math.min(image.naturalWidth, image.naturalHeight); + this.#profilePictureImage = image; + this.#profilePictureCrop = { + x: (image.naturalWidth - size) / 2, + y: (image.naturalHeight - size) / 2, + size: size, + }; + this.#profilePictureZoomField.value = '100'; + this.#profilePictureEditor.classList.remove('hidden'); + this.#renderProfilePictureEditor(); + }; + image.src = this.#profilePictureObjectUrl; + } + + #revokeProfilePictureObjectUrl() { + if (this.#profilePictureObjectUrl != null) { + URL.revokeObjectURL(this.#profilePictureObjectUrl); + this.#profilePictureObjectUrl = null; + } + } + + #resolveProfilePictureFormat(fileName) { + const fileExtension = fileName.split('.').pop()?.toLowerCase(); + if (fileExtension === 'png') { + return {extension: 'png', mimeType: 'image/png'}; + } + if (fileExtension === 'jpg' || fileExtension === 'jpeg') { + return {extension: fileExtension, mimeType: 'image/jpeg'}; + } + return null; + } + + #renderProfilePictureEditor() { + if (this.#profilePictureImage == null || this.#profilePictureCrop == null) { + return; + } + + const canvas = this.#profilePictureEditorCanvas; + const context = canvas.getContext('2d'); + const image = this.#profilePictureImage; + const crop = this.#profilePictureCrop; + const scale = Math.min(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight); + const drawWidth = image.naturalWidth * scale; + const drawHeight = image.naturalHeight * scale; + const offsetX = (canvas.width - drawWidth) / 2; + const offsetY = (canvas.height - drawHeight) / 2; + + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); + + const cropX = offsetX + crop.x * scale; + const cropY = offsetY + crop.y * scale; + const cropSize = crop.size * scale; + + context.fillStyle = 'rgba(0, 0, 0, 0.45)'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.clearRect(cropX, cropY, cropSize, cropSize); + context.strokeStyle = '#f7f0e7'; + context.lineWidth = 2; + context.strokeRect(cropX, cropY, cropSize, cropSize); + } + + #updateProfilePictureCropFromZoom() { + if (this.#profilePictureImage == null || this.#profilePictureCrop == null) { + return; + } + + const image = this.#profilePictureImage; + const shortestSide = Math.min(image.naturalWidth, image.naturalHeight); + const nextSize = shortestSide * (Number(this.#profilePictureZoomField.value) / 100); + const centerX = this.#profilePictureCrop.x + this.#profilePictureCrop.size / 2; + const centerY = this.#profilePictureCrop.y + this.#profilePictureCrop.size / 2; + + this.#profilePictureCrop.size = nextSize; + this.#profilePictureCrop.x = Math.max(0, Math.min(image.naturalWidth - nextSize, centerX - nextSize / 2)); + this.#profilePictureCrop.y = Math.max(0, Math.min(image.naturalHeight - nextSize, centerY - nextSize / 2)); + this.#renderProfilePictureEditor(); + } + + #eventToProfilePictureCoordinates(event) { + if (this.#profilePictureImage == null) { + return null; + } + + const canvas = this.#profilePictureEditorCanvas; + const image = this.#profilePictureImage; + const bounds = canvas.getBoundingClientRect(); + const scale = Math.min(canvas.width / image.naturalWidth, canvas.height / image.naturalHeight); + const drawWidth = image.naturalWidth * scale; + const drawHeight = image.naturalHeight * scale; + const offsetX = (canvas.width - drawWidth) / 2; + const offsetY = (canvas.height - drawHeight) / 2; + const canvasX = (event.clientX - bounds.left) * (canvas.width / bounds.width); + const canvasY = (event.clientY - bounds.top) * (canvas.height / bounds.height); + + if (canvasX < offsetX || canvasX > offsetX + drawWidth || canvasY < offsetY || canvasY > offsetY + drawHeight) { + return null; + } + + return { + x: (canvasX - offsetX) / scale, + y: (canvasY - offsetY) / scale, + }; + } + + #startDraggingProfilePicture(event) { + if (this.#profilePictureCrop == null) { + return; + } + + const coordinates = this.#eventToProfilePictureCoordinates(event); + if (coordinates == null) { + return; + } + + const crop = this.#profilePictureCrop; + const isInsideCrop = + coordinates.x >= crop.x && + coordinates.x <= crop.x + crop.size && + coordinates.y >= crop.y && + coordinates.y <= crop.y + crop.size; + + if (isInsideCrop) { + this.#isDraggingProfilePicture = true; + this.#profilePictureDragOffsetX = coordinates.x - crop.x; + this.#profilePictureDragOffsetY = coordinates.y - crop.y; + this.#profilePictureEditorCanvas.setPointerCapture(event.pointerId); + } + } + + #dragProfilePicture(event) { + if (!this.#isDraggingProfilePicture || this.#profilePictureCrop == null || this.#profilePictureImage == null) { + return; + } + + const coordinates = this.#eventToProfilePictureCoordinates(event); + if (coordinates == null) { + return; + } + + const crop = this.#profilePictureCrop; + crop.x = Math.max(0, Math.min(this.#profilePictureImage.naturalWidth - crop.size, coordinates.x - this.#profilePictureDragOffsetX)); + crop.y = Math.max(0, Math.min(this.#profilePictureImage.naturalHeight - crop.size, coordinates.y - this.#profilePictureDragOffsetY)); + this.#renderProfilePictureEditor(); + } + + #stopDraggingProfilePicture(event) { + if (this.#isDraggingProfilePicture) { + this.#isDraggingProfilePicture = false; + if (event.pointerId != null && this.#profilePictureEditorCanvas.hasPointerCapture(event.pointerId)) { + this.#profilePictureEditorCanvas.releasePointerCapture(event.pointerId); + } + } + } + + #uploadProfilePicture() { + if (this.#profilePictureImage == null || this.#profilePictureCrop == null) { + UI.pushErrorNotification('Please choose a profile picture first', 3_000); + return; + } + + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = PROFILE_PICTURE_SIZE_PX; + cropCanvas.height = PROFILE_PICTURE_SIZE_PX; + cropCanvas.getContext('2d').drawImage( + this.#profilePictureImage, + this.#profilePictureCrop.x, + this.#profilePictureCrop.y, + this.#profilePictureCrop.size, + this.#profilePictureCrop.size, + 0, + 0, + PROFILE_PICTURE_SIZE_PX, + PROFILE_PICTURE_SIZE_PX + ); + + cropCanvas.toBlob(blob => { + if (blob == null) { + UI.pushErrorNotification('Failed to convert image to the required format', 3_000); + return; + } + + if (blob.size > PROFILE_PICTURE_MAX_BYTES) { + UI.pushErrorNotification('Profile picture limited to 500KB', 3_000); + return; + } + + const formData = new FormData(); + formData.append('file', blob, `profile-picture.${this.#profilePictureExtension}`); + + postAndHandle(PROFILE_PICTURE_UPLOAD_URL, formData, json => { + this.#setProfilePicturePreview(json.profilePictureUrl); + UI.pushInfoNotification('Profile picture successfully updated!', UI_NOTIFICATION_TIMEOUT); + }); + }, this.#profilePictureMimeType, PROFILE_PICTURE_JPEG_QUALITY); + } + #fetchEmailAddressSettings() { getAndHandle(EMAIL_SETTINGS_URL, json => { this.#emailAddressField.value = json.email; diff --git a/webapp/src/main/resources/templates/user_profile.html b/webapp/src/main/resources/templates/user_profile.html index abf257ca2..a92896c7c 100644 --- a/webapp/src/main/resources/templates/user_profile.html +++ b/webapp/src/main/resources/templates/user_profile.html @@ -21,6 +21,7 @@ {{body_init}}
+ {{profile_picture_panel}}
diff --git a/webapp/src/main/resources/templates/user_settings.html b/webapp/src/main/resources/templates/user_settings.html index 1ff9212dd..f03fae406 100644 --- a/webapp/src/main/resources/templates/user_settings.html +++ b/webapp/src/main/resources/templates/user_settings.html @@ -25,6 +25,25 @@

Country


+

Profile Picture

+
+ profile picture preview + +
+ +
+ +
+

Email Notifications Settings


diff --git a/webapp/src/test/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRendererTest.kt b/webapp/src/test/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRendererTest.kt new file mode 100644 index 000000000..78d254361 --- /dev/null +++ b/webapp/src/test/kotlin/io/elephantchess/webapp/rendering/UserProfilePageRendererTest.kt @@ -0,0 +1,82 @@ +package io.elephantchess.webapp.rendering + +import io.elephantchess.htmlrenderer.HtmlRenderer +import io.elephantchess.htmlrenderer.SimpleValueTagResolver +import io.elephantchess.servicelayer.dto.user.GameStatsResponse +import io.elephantchess.servicelayer.dto.user.NumberOfGamesPerTimeCategory +import io.elephantchess.servicelayer.dto.user.NumberOfOutcomes +import io.elephantchess.servicelayer.dto.user.RatingsPerTimeCategory +import io.elephantchess.servicelayer.dto.user.UserProfile +import io.elephantchess.servicelayer.services.UserProfileAnalyticsService +import kotlinx.coroutines.test.runTest +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UserProfilePageRendererTest { + + private val userProfileAnalyticsService = mock() + private val htmlRenderer = HtmlRenderer( + isMinificationEnabled = false, + cdnFolder = null, + webTemplateRenderer = WebTemplateRenderer( + baseTagResolvers = listOf( + SimpleValueTagResolver("header_init", ""), + SimpleValueTagResolver("body_init", ""), + SimpleValueTagResolver("footer", ""), + SimpleValueTagResolver("apex_charts", ""), + ) + ) + ) + private val gameStats = GameStatsResponse( + ratings = RatingsPerTimeCategory(1000, 1001, 1002, 1003, 1004, 1005), + pvp = NumberOfGamesPerTimeCategory( + bullet = NumberOfOutcomes(1, 2, 3), + blitz = NumberOfOutcomes(4, 5, 6), + rapid = NumberOfOutcomes(7, 8, 9), + classical = NumberOfOutcomes(10, 11, 12), + severalDays = NumberOfOutcomes(13, 14, 15), + correspondence = NumberOfOutcomes(16, 17, 18), + ), + ) + + @Test + fun `renderUserProfile includes profile picture when available`() = runTest { + whenever(userProfileAnalyticsService.fetchGameRatings("user-1")).thenReturn(gameStats) + val renderer = UserProfilePageRenderer(htmlRenderer, userProfileAnalyticsService) + + val html = renderer.renderUserProfile( + UserProfile( + userId = "user-1", + username = "alice", + country = "BE", + profileDescription = "hello", + puzzleRating = 1200, + profilePictureUrl = "https://cdn.elephantchess.io/local-backup/profile-pictures/user-1.png", + ) + ) + + assertTrue(html.contains("""id="profile-picture"""")) + assertTrue(html.contains("""src="https://cdn.elephantchess.io/local-backup/profile-pictures/user-1.png"""")) + } + + @Test + fun `renderUserProfile omits profile picture when unavailable`() = runTest { + whenever(userProfileAnalyticsService.fetchGameRatings("user-1")).thenReturn(gameStats) + val renderer = UserProfilePageRenderer(htmlRenderer, userProfileAnalyticsService) + + val html = renderer.renderUserProfile( + UserProfile( + userId = "user-1", + username = "alice", + country = null, + profileDescription = null, + puzzleRating = 1200, + ) + ) + + assertFalse(html.contains("""id="profile-picture"""")) + } +}