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)
+ """""".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}}