Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2baaa55
Initial plan
Copilot May 13, 2026
45d38bd
feat: add profile picture upload flow
Copilot May 13, 2026
46a7db5
refactor: normalize profile picture format handling
Copilot May 13, 2026
453bb8f
chore: polish profile picture upload details
Copilot May 13, 2026
833993c
chore: clarify profile picture limits and errors
Copilot May 13, 2026
a9fcf96
fix: harden profile picture rendering and cleanup
Copilot May 13, 2026
2dc2ebd
fix: scope profile picture CDN paths by runtime profile
Copilot May 13, 2026
c4dd44e
test: simplify profile picture service config fixture
Copilot May 13, 2026
41db20b
test: align profile picture URL fixtures
Copilot May 13, 2026
250b63c
fix: validate profile segment for CDN picture paths
Copilot May 13, 2026
12e7966
refactor: tighten profile path validation
Copilot May 13, 2026
a1b99c9
Merge origin/master into copilot/add-profile-picture-upload
Copilot May 26, 2026
e111e4a
Merge origin/master into copilot/add-profile-picture-upload
Copilot May 27, 2026
d644801
fix: wire profile picture service in Koin
Copilot May 28, 2026
ce5f016
Merge remote-tracking branch 'origin/master' into copilot/add-profile…
Copilot Jun 2, 2026
738980b
Map public profile query to User POJO
Copilot Jun 2, 2026
0064e81
Reuse shared post helper for profile picture upload
Copilot Jun 2, 2026
bde44b3
Merge origin/master into profile picture branch
Copilot Jun 8, 2026
d640f48
Merge origin/master into profile picture branch
Copilot Jun 9, 2026
26739c4
Merge origin/master into profile picture branch
Copilot Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2470,4 +2470,9 @@
<changeSet id="0107" author="benckx">
<comment>Indexes for latest move analysis aggregation and analyzed flag lookup</comment>
</changeSet>
<changeSet id="0108" author="benckx">
<addColumn tableName="user">
<column name="profile_picture_extension" type="varchar(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -2697,4 +2697,9 @@
ON bot_game (analysis_end_time, analyzed_from_batch);
</sql>
</changeSet>
<changeSet id="0108" author="benckx">
<addColumn tableName="user">
<column name="profile_picture_extension" type="varchar(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ->
Expand Down Expand Up @@ -80,11 +82,12 @@ class UserDaoService(private val dslContext: DSLContext, val logger: KLogger) {
return id!!
}

suspend fun fetchProfileSettings(userId: String): Record2<String, String>? {
suspend fun fetchProfileSettings(userId: String): Record3<String?, String?, String?>? {
return dslContext
.select(
USER.DESCRIPTION,
USER.COUNTRY
USER.COUNTRY,
profilePictureExtensionField
)
.from(USER)
.where(USER.ID.eq(userId))
Expand All @@ -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<User>()
}

suspend fun fetchNotificationSettings(userId: String): NotificationsSettingsRecord? {
return dslContext
.select(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ private fun applicativeModule(eagerAllowed: Boolean) = module {
// users
singleAuto<ContentSectionFeedbackService>()
singleAuto<UserService>(eager = eagerAllowed)
singleAuto<UserProfilePictureService>()
singleAuto<UserProfileAnalyticsService>()
singleAuto<GlobalAnalyticsService>(eager = eagerAllowed)
singleAuto<UserSessionService>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.elephantchess.servicelayer.dto.user

data class ProfilePictureUploadResponse(
val profilePictureUrl: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ data class UserProfile(
val country: String?,
val profileDescription: String?,
val puzzleRating: Int,
val profilePictureUrl: String? = null,
)
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)
)
}
}
Expand All @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
Loading