diff --git a/README.md b/README.md index c9c4655..060616c 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ deploy/ # systemd unit, пример nginx (`identifier` — email или E.164-телефон), `POST /api/auth/refresh|logout` - `GET|PATCH|DELETE /api/auth/me`, `POST /api/auth/me/password`, `POST /api/auth/me/identifiers`, `PATCH /api/auth/me/preferences` +- `POST|DELETE /api/auth/me/avatar` (круглый аватар: загрузка готового квадрата), + `GET /api/avatars/:userID/:version?size=large` (публичная отдача аватара) - `GET|POST /api/albums`, `GET|PATCH|DELETE /api/albums/:id` - `GET /api/albums/:id/members`, `POST /api/albums/:id/invites`, `POST /api/invites/:token/accept`, `PATCH|DELETE /api/albums/:id/members/:user_id` diff --git a/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt index 7e4f657..1b957e9 100644 --- a/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt +++ b/android/app/src/main/java/com/galo/android/ui/GaloViewModelFactory.kt @@ -21,7 +21,7 @@ class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container) modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container) modelClass.isAssignableFrom(ProfileViewModel::class.java) -> - ProfileViewModel(container.api, container.tokenStore) + ProfileViewModel(container.api, container.tokenStore, container.apiClient.objectUrls::resolve) else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}") } return viewModel as T diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt index 46bde2b..4abe102 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.CloudUpload -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -40,9 +39,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.galo.android.domain.model.Album import com.galo.android.ui.GaloViewModelFactory import com.galo.android.ui.common.GaloAsyncImage +import com.galo.android.ui.common.UserAvatar const val TAG_ALBUMS_GRID = "albums_grid" const val TAG_ALBUMS_OFFLINE = "albums_offline" +const val TAG_ALBUMS_AVATAR = "albums_avatar" const val TAG_ALBUM_CARD_PREFIX = "album_card_" @OptIn(ExperimentalMaterial3Api::class) @@ -81,8 +82,8 @@ fun AlbumsContent( TopAppBar( title = { Text("Альбомы") }, actions = { - IconButton(onClick = onOpenProfile) { - Icon(Icons.Filled.Person, contentDescription = "Профиль") + IconButton(onClick = onOpenProfile, modifier = Modifier.testTag(TAG_ALBUMS_AVATAR)) { + UserAvatar(url = state.avatarUrl, contentDescription = "Профиль", size = 28.dp) } IconButton(onClick = onOpenUploads) { Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки") diff --git a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt index f718102..baade21 100644 --- a/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/albums/AlbumsViewModel.kt @@ -19,6 +19,7 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { val loading: Boolean = false, val offline: Boolean = false, val error: String? = null, + val avatarUrl: String? = null, ) private data class Status( @@ -29,11 +30,14 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { private val status = MutableStateFlow(Status()) + private val avatar = MutableStateFlow(null) + val state: StateFlow = combine( container.albumRepository.observeAlbums(), status, - ) { albums, s -> - UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error) + avatar, + ) { albums, s, a -> + UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error, avatarUrl = a) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true)) init { @@ -52,6 +56,11 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() { ) } } + viewModelScope.launch { + runCatching { container.api.me() }.onSuccess { user -> + avatar.value = container.apiClient.objectUrls.resolve(user.avatarUrl) + } + } } fun logout(onDone: () -> Unit) { diff --git a/android/app/src/main/java/com/galo/android/ui/common/UserAvatar.kt b/android/app/src/main/java/com/galo/android/ui/common/UserAvatar.kt new file mode 100644 index 0000000..f0afc6e --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/common/UserAvatar.kt @@ -0,0 +1,55 @@ +package com.galo.android.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp + +const val TAG_USER_AVATAR_IMAGE = "user_avatar_image" +const val TAG_USER_AVATAR_PLACEHOLDER = "user_avatar_placeholder" + +/** + * A round user avatar: the image when a URL is known, otherwise a neutral + * silhouette placeholder. The frame is always circular. + */ +@Composable +fun UserAvatar( + url: String?, + contentDescription: String?, + size: Dp, + modifier: Modifier = Modifier, +) { + if (!url.isNullOrBlank()) { + GaloAsyncImage( + url = url, + contentDescription = contentDescription, + modifier = modifier.size(size).clip(CircleShape).testTag(TAG_USER_AVATAR_IMAGE), + ) + } else { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .testTag(TAG_USER_AVATAR_PLACEHOLDER), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(size * 0.6f), + ) + } + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/profile/AvatarCropDialog.kt b/android/app/src/main/java/com/galo/android/ui/profile/AvatarCropDialog.kt new file mode 100644 index 0000000..451eb67 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/profile/AvatarCropDialog.kt @@ -0,0 +1,151 @@ +package com.galo.android.ui.profile + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import java.io.ByteArrayOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +const val TAG_CROP_CONFIRM = "avatar_crop_confirm" +const val TAG_CROP_CANCEL = "avatar_crop_cancel" + +/** + * A square-picker cropper. The user pans and zooms the image inside a circular + * mask; confirming renders a 512x512 JPEG of the visible square. All cropping + * happens on-device, so the server only ever receives a ready square. + */ +@Composable +fun AvatarCropDialog( + image: ImageBitmap, + onCancel: () -> Unit, + onConfirm: (ByteArray) -> Unit, +) { + var zoom by remember { mutableStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + var viewPx by remember { mutableStateOf(IntSize.Zero) } + + fun clamp() { + if (viewPx.width == 0) return + val s = max(viewPx.width.toFloat() / image.width, viewPx.height.toFloat() / image.height) * zoom + val dw = image.width * s + val dh = image.height * s + val mx = max(0f, (dw - viewPx.width) / 2f) + val my = max(0f, (dh - viewPx.height) / 2f) + offset = Offset(offset.x.coerceIn(-mx, mx), offset.y.coerceIn(-my, my)) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.9f) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Кадрирование", style = MaterialTheme.typography.titleMedium) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .onSizeChanged { viewPx = it } + .pointerInput(Unit) { + detectDragGestures { change, drag -> + change.consume() + offset += drag + clamp() + } + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val s = max(size.width / image.width, size.height / image.height) * zoom + val dw = image.width * s + val dh = image.height * s + val left = (size.width - dw) / 2f + offset.x + val top = (size.height - dh) / 2f + offset.y + drawImage( + image = image, + dstOffset = IntOffset(left.roundToInt(), top.roundToInt()), + dstSize = IntSize(dw.roundToInt(), dh.roundToInt()), + ) + } + } + Slider( + value = zoom, + onValueChange = { + zoom = it + clamp() + }, + valueRange = 1f..3f, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onCancel, modifier = Modifier.testTag(TAG_CROP_CANCEL)) { + Text("Отмена") + } + Button( + onClick = { onConfirm(renderCropped(image, zoom, offset, viewPx)) }, + modifier = Modifier.testTag(TAG_CROP_CONFIRM), + ) { + Text("Сохранить") + } + } + } + } +} + +private fun renderCropped(image: ImageBitmap, zoom: Float, offset: Offset, viewPx: IntSize): ByteArray { + val bitmap = image.asAndroidBitmap() + val s = max(viewPx.width.toFloat() / bitmap.width, viewPx.height.toFloat() / bitmap.height) * zoom + val side = viewPx.width / s + val cx = bitmap.width / 2f - offset.x / s + val cy = bitmap.height / 2f - offset.y / s + val x = (cx - side / 2f).roundToInt().coerceIn(0, (bitmap.width - 1).coerceAtLeast(0)) + val y = (cy - side / 2f).roundToInt().coerceIn(0, (bitmap.height - 1).coerceAtLeast(0)) + val size = side.roundToInt().coerceAtLeast(1).coerceAtMost(minOf(bitmap.width - x, bitmap.height - y)) + val cropped = Bitmap.createBitmap(bitmap, x, y, size, size) + val scaled = Bitmap.createScaledBitmap(cropped, 512, 512, true) + return ByteArrayOutputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.JPEG, 90, out) + out.toByteArray() + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt b/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt index d48505d..0c85f5e 100644 --- a/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/profile/ProfileScreen.kt @@ -31,6 +31,18 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.galo.android.ui.GaloViewModelFactory +import com.galo.android.ui.common.UserAvatar +import android.graphics.ImageDecoder +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext const val TAG_PROFILE_EMAIL = "profile_email" const val TAG_PROFILE_PHONE = "profile_phone" @@ -44,6 +56,9 @@ const val TAG_PROFILE_NEW_PASSWORD = "profile_new_password" const val TAG_PROFILE_CHANGE_PASSWORD = "profile_change_password" const val TAG_PROFILE_DELETE_PASSWORD = "profile_delete_password" const val TAG_PROFILE_DELETE = "profile_delete" +const val TAG_PROFILE_AVATAR = "profile_avatar" +const val TAG_PROFILE_AVATAR_PICK = "profile_avatar_pick" +const val TAG_PROFILE_AVATAR_REMOVE = "profile_avatar_remove" @Composable fun ProfileScreen( @@ -54,6 +69,17 @@ fun ProfileScreen( val viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory) val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var cropImage by remember { mutableStateOf(null) } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + runCatching { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source).asImageBitmap() + }.onSuccess { cropImage = it } + } + } + LaunchedEffect(Unit) { viewModel.loggedOut.collect { onLoggedOut() } } @@ -70,8 +96,21 @@ fun ProfileScreen( onChangePassword = viewModel::changePassword, onDeletePasswordChange = viewModel::onDeletePasswordChange, onDeleteAccount = viewModel::deleteAccount, + onPickAvatar = { picker.launch("image/*") }, + onRemoveAvatar = viewModel::removeAvatar, onBack = onBack, ) + + cropImage?.let { image -> + AvatarCropDialog( + image = image, + onCancel = { cropImage = null }, + onConfirm = { bytes -> + cropImage = null + viewModel.uploadAvatar(bytes) + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -88,6 +127,8 @@ fun ProfileContent( onChangePassword: () -> Unit, onDeletePasswordChange: (String) -> Unit, onDeleteAccount: () -> Unit, + onPickAvatar: () -> Unit = {}, + onRemoveAvatar: () -> Unit = {}, onBack: () -> Unit, ) { Scaffold( @@ -121,6 +162,31 @@ fun ProfileContent( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + UserAvatar( + url = state.avatarUrl, + contentDescription = state.name, + size = 80.dp, + modifier = Modifier.testTag(TAG_PROFILE_AVATAR), + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onPickAvatar, modifier = Modifier.testTag(TAG_PROFILE_AVATAR_PICK)) { + Text(if (state.avatarUrl != null) "Заменить фото" else "Загрузить фото") + } + if (state.avatarUrl != null) { + OutlinedButton( + onClick = onRemoveAvatar, + modifier = Modifier.testTag(TAG_PROFILE_AVATAR_REMOVE), + ) { + Text("Удалить фото") + } + } + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Идентификаторы", style = MaterialTheme.typography.titleMedium) Text( diff --git a/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt b/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt index 8c78048..e1bb880 100644 --- a/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt +++ b/android/app/src/main/java/com/galo/android/ui/profile/ProfileViewModel.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody /** * Drives the profile screen. Password change and account deletion clear the @@ -20,6 +22,7 @@ import kotlinx.coroutines.launch class ProfileViewModel( private val api: ProfileApi, private val tokenStore: TokenStore, + private val resolveUrl: (String?) -> String? = { it }, ) : ViewModel() { data class UiState( @@ -27,6 +30,7 @@ class ProfileViewModel( val name: String = "", val email: String? = null, val phone: String? = null, + val avatarUrl: String? = null, val newEmail: String = "", val newPhone: String = "", val currentPassword: String = "", @@ -52,7 +56,13 @@ class ProfileViewModel( try { val user = api.me() _state.update { - it.copy(loading = false, name = user.name, email = user.email, phone = user.phone) + it.copy( + loading = false, + name = user.name, + email = user.email, + phone = user.phone, + avatarUrl = resolveUrl(user.avatarUrl), + ) } } catch (e: ApiException) { _state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) } @@ -154,12 +164,41 @@ class ProfileViewModel( _loggedOut.tryEmit(Unit) } + /** Installs a cropped square avatar; [jpeg] is the encoded image bytes. */ + fun uploadAvatar(jpeg: ByteArray) { + viewModelScope.launch { + _state.update { it.copy(error = null, notice = null) } + try { + val user = api.uploadAvatar(jpeg.toRequestBody("image/jpeg".toMediaType())) + _state.update { it.copy(avatarUrl = resolveUrl(user.avatarUrl), notice = "Аватар обновлён") } + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Не удалось загрузить аватар") } + } + } + } + + fun removeAvatar() { + viewModelScope.launch { + _state.update { it.copy(error = null, notice = null) } + try { + val user = api.deleteAvatar() + _state.update { it.copy(avatarUrl = resolveUrl(user.avatarUrl), notice = "Аватар удалён") } + } catch (e: ApiException) { + _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } + } catch (e: Exception) { + _state.update { it.copy(error = e.message ?: "Не удалось удалить аватар") } + } + } + } + private inline fun mutate(notice: String, crossinline block: suspend () -> com.galo.android.data.network.dto.UserDto) { viewModelScope.launch { _state.update { it.copy(error = null, notice = null) } try { val user = block() - _state.update { it.copy(name = user.name, email = user.email, phone = user.phone, notice = notice) } + _state.update { it.copy(name = user.name, email = user.email, phone = user.phone, avatarUrl = resolveUrl(user.avatarUrl), notice = notice) } } catch (e: ApiException) { _state.update { it.copy(error = ErrorMapper.userMessage(e.error)) } } catch (e: Exception) { diff --git a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt index 66d1871..534cf24 100644 --- a/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/albums/AlbumsContentTest.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import com.galo.android.domain.model.Album +import com.galo.android.ui.common.TAG_USER_AVATAR_IMAGE +import com.galo.android.ui.common.TAG_USER_AVATAR_PLACEHOLDER import com.galo.android.ui.theme.GaloTheme import org.junit.Rule import org.junit.Test @@ -89,4 +91,39 @@ class AlbumsContentTest { } compose.onNodeWithText("Пока нет альбомов").assertIsDisplayed() } + + @Test + fun `header shows the silhouette placeholder without an avatar`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + AlbumsContent( + state = AlbumsViewModel.UiState(), + onOpenAlbum = {}, + onOpenUploads = {}, + onOpenProfile = {}, + onRefresh = {}, + onLogout = {}, + ) + } + } + compose.onNodeWithTag(TAG_ALBUMS_AVATAR).assertExists() + compose.onNodeWithTag(TAG_USER_AVATAR_PLACEHOLDER, useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `header shows the avatar image when set`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + AlbumsContent( + state = AlbumsViewModel.UiState(avatarUrl = "http://host/api/avatars/u1/v1"), + onOpenAlbum = {}, + onOpenUploads = {}, + onOpenProfile = {}, + onRefresh = {}, + onLogout = {}, + ) + } + } + compose.onNodeWithTag(TAG_USER_AVATAR_IMAGE, useUnmergedTree = true).assertIsDisplayed() + } } diff --git a/android/app/src/test/java/com/galo/android/ui/profile/AvatarCropDialogTest.kt b/android/app/src/test/java/com/galo/android/ui/profile/AvatarCropDialogTest.kt new file mode 100644 index 0000000..9460dcc --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/profile/AvatarCropDialogTest.kt @@ -0,0 +1,57 @@ +package com.galo.android.ui.profile + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w411dp-h2400dp") +class AvatarCropDialogTest { + + @get:Rule + val compose = createComposeRule() + + private fun image() = Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).asImageBitmap() + + @Test + fun `confirm renders a square jpeg`() { + var result: ByteArray? = null + compose.setContent { + GaloTheme(dynamicColor = false) { + AvatarCropDialog(image = image(), onCancel = {}, onConfirm = { result = it }) + } + } + compose.onNodeWithTag(TAG_CROP_CONFIRM).performClick() + compose.waitForIdle() + + assertNotNull(result) + val decoded = BitmapFactory.decodeByteArray(result, 0, result!!.size) + assertNotNull(decoded) + assertEquals(512, decoded.width) + assertEquals(512, decoded.height) + } + + @Test + fun `cancel invokes the callback`() { + var cancelled = false + compose.setContent { + GaloTheme(dynamicColor = false) { + AvatarCropDialog(image = image(), onCancel = { cancelled = true }, onConfirm = {}) + } + } + compose.onNodeWithTag(TAG_CROP_CANCEL).performClick() + assertTrue(cancelled) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt b/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt index e6464e8..930b3bd 100644 --- a/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/profile/ProfileContentTest.kt @@ -91,4 +91,24 @@ class ProfileContentTest { compose.onNodeWithTag(TAG_PROFILE_DELETE).performClick() assertTrue(deleted) } + + @Test + fun `avatar placeholder and pick button are shown without a photo`() { + render(ProfileViewModel.UiState(loading = false, name = "Tester")) + compose.onNodeWithTag(TAG_PROFILE_AVATAR).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_AVATAR_PICK).assertIsDisplayed() + compose.onNodeWithTag(TAG_PROFILE_AVATAR_REMOVE).assertDoesNotExist() + } + + @Test + fun `remove button appears once an avatar is set`() { + render( + ProfileViewModel.UiState( + loading = false, + name = "Tester", + avatarUrl = "http://host/api/avatars/u1/v1", + ), + ) + compose.onNodeWithTag(TAG_PROFILE_AVATAR_REMOVE).assertIsDisplayed() + } } diff --git a/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt b/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt index ad84a31..9bbeb1d 100644 --- a/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/profile/ProfileViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import okhttp3.RequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -37,6 +38,8 @@ class ProfileViewModelTest { private class FakeApi : ProfileApi { var changed: Pair? = null var deletedPassword: String? = null + var uploadedAvatar = false + var deletedAvatar = false override suspend fun me(): UserDto = UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = "Tester") @@ -59,6 +62,14 @@ class ProfileViewModelTest { override suspend fun deleteAccount(password: String) { deletedPassword = password } + + override suspend fun uploadAvatar(body: RequestBody): UserDto = + UserDto(id = "u1", email = "a@test.local", name = "Tester", avatarUrl = "/api/avatars/u1/v1") + .also { uploadedAvatar = true } + + override suspend fun deleteAvatar(): UserDto = + UserDto(id = "u1", email = "a@test.local", name = "Tester", avatarUrl = null) + .also { deletedAvatar = true } } @Test @@ -112,4 +123,26 @@ class ProfileViewModelTest { assertEquals("New Name", vm.state.value.name) assertTrue(vm.state.value.notice != null) } + + @Test + fun `avatar upload stores the resolved url`() { + val api = FakeApi() + val vm = ProfileViewModel(api, InMemoryTokenStore()) { it?.let { raw -> "http://host$raw" } } + + vm.uploadAvatar(byteArrayOf(1, 2, 3)) + + assertTrue(api.uploadedAvatar) + assertEquals("http://host/api/avatars/u1/v1", vm.state.value.avatarUrl) + } + + @Test + fun `avatar removal clears the url`() { + val api = FakeApi() + val vm = ProfileViewModel(api, InMemoryTokenStore()) { it?.let { raw -> "http://host$raw" } } + + vm.removeAvatar() + + assertTrue(api.deletedAvatar) + assertNull(vm.state.value.avatarUrl) + } } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a8f152b..eea9d0c 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -14,6 +14,7 @@ import ( assets "galo" "galo/internal/albums" "galo/internal/auth" + "galo/internal/avatar" "galo/internal/config" "galo/internal/httpx" "galo/internal/members" @@ -38,6 +39,8 @@ type Server struct { auth *auth.Service + avatar *avatar.Service + membersSvc *members.Service photos *photos.Service @@ -71,6 +74,8 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e photosSvc.SetTempDir(cfg.Video.TempDir) photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency) + avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st) + socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc) shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc) @@ -83,6 +88,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e hub: hub, albums: albumsSvc, auth: authSvc, + avatar: avatarSvc, membersSvc: membersSvc, photos: photosSvc, worker: photosWorker, @@ -106,6 +112,7 @@ func (s *Server) Router() *gin.Engine { authHandler := auth.NewHandler(s.auth) albumsHandler := albums.NewHandler(s.albums) + avatarHandler := avatar.NewHandler(s.avatar) membersHandler := members.NewHandler(s.membersSvc) photosHandler := photos.NewHandler(s.photos) socialHandler := social.NewHandler(s.social) @@ -116,6 +123,11 @@ func (s *Server) Router() *gin.Engine { api.GET("/health", s.health) api.GET("/objects/*key", s.serveObject) api.HEAD("/objects/*key", s.serveObject) + // Avatars are read by tags and image loaders, which cannot send an + // Authorization header, so the read route is public. Upload and removal + // stay behind authentication. The URL carries an opaque per-generation + // version segment, so the path is not guessable from a user id alone. + api.GET("/avatars/:userID/:version", avatarHandler.Serve) api.POST("/auth/register", authHandler.Register) api.POST("/auth/login", authHandler.Login) @@ -134,6 +146,8 @@ func (s *Server) Router() *gin.Engine { authed.GET("/auth/me", authHandler.Me) authed.PATCH("/auth/me", authHandler.UpdateMe) + authed.POST("/auth/me/avatar", avatarHandler.Upload) + authed.DELETE("/auth/me/avatar", avatarHandler.Remove) authed.POST("/auth/me/password", authHandler.ChangePassword) authed.POST("/auth/me/identifiers", authHandler.AddIdentifier) authed.DELETE("/auth/me", authHandler.DeleteMe) diff --git a/backend/internal/app/avatar_flow_test.go b/backend/internal/app/avatar_flow_test.go new file mode 100644 index 0000000..e513afb --- /dev/null +++ b/backend/internal/app/avatar_flow_test.go @@ -0,0 +1,277 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + assets "galo" + "galo/internal/auth" + "galo/internal/config" + "galo/internal/db" + "galo/internal/models" + "galo/internal/photos" + "galo/internal/sse" + "galo/internal/storage" + "galo/internal/store" +) + +// newAvatarServer builds a full router over SQLite and FS storage, plus a valid +// access token for a freshly created user. It needs no external database. +func newAvatarServer(t *testing.T) (*gin.Engine, string, string, store.Repos) { + t.Helper() + gin.SetMode(gin.TestMode) + + migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") + if err != nil { + t.Fatalf("migrations fs: %v", err) + } + path := filepath.Join(t.TempDir(), "meta.db") + if err := db.MigrateSQLite(path, migFS); err != nil { + t.Fatalf("migrate: %v", err) + } + database, err := db.OpenSQLite(path) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { database.Close() }) + repos := store.NewSQLite(database) + + signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute) + if err != nil { + t.Fatalf("signer: %v", err) + } + blob, err := storage.NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("fs: %v", err) + } + + tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, repos, func(context.Context) error { return nil }, tm, blob, sse.NewHub(1)) + + uid := auth.NewID() + u := &models.User{ID: uid, Email: uid[:8] + "@test.local", PasswordHash: "x", Name: "Tester"} + if err := repos.Auth.CreateUser(context.Background(), u); err != nil { + t.Fatalf("create user: %v", err) + } + access, err := tm.GenerateAccess(uid, u.Email) + if err != nil { + t.Fatalf("access token: %v", err) + } + return srv.Router(), access, uid, repos +} + +func multipartFile(t *testing.T, field, filename string, data []byte) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile(field, filename) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := fw.Write(data); err != nil { + t.Fatalf("write form file: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + return &buf, w.FormDataContentType() +} + +func doAuthed(r *gin.Engine, method, target string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, target, body) + req.Header.Set("Content-Type", contentType) + } else { + req = httptest.NewRequest(method, target, nil) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +func avatarURLOf(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + User models.SelfUser `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body %q: %v", rec.Body.String(), err) + } + return body.User.AvatarURL +} + +func TestAvatarWritesRequireAuth(t *testing.T) { + r, _, _, _ := newAvatarServer(t) + img := makeTestPNG(t) + + body, ct := multipartFile(t, "file", "a.png", img) + if rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("upload without token = %d, want 401", rec.Code) + } + if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("delete without token = %d, want 401", rec.Code) + } +} + +func TestAvatarUploadServeReplaceRemove(t *testing.T) { + r, token, _, _ := newAvatarServer(t) + img := makeTestPNG(t) + + // Upload. + body, ct := multipartFile(t, "file", "a.png", img) + rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) + if rec.Code != http.StatusOK { + t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String()) + } + first := avatarURLOf(t, rec) + if first == "" || !strings.HasPrefix(first, "/api/avatars/") { + t.Fatalf("avatar_url = %q", first) + } + + // The image loader fetches without an Authorization header: the read route + // is public and returns a WebP blob. + rec = doAuthed(r, http.MethodGet, first, nil, "", "") + if rec.Code != http.StatusOK { + t.Fatalf("serve = %d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.HasPrefix(rec.Body.Bytes(), []byte("RIFF")) || !bytes.Contains(rec.Body.Bytes()[:16], []byte("WEBP")) { + t.Fatalf("served body is not WebP") + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { + t.Fatalf("cache-control = %q", cc) + } + + // A non-image upload is rejected and does not change the avatar. + body, ct = multipartFile(t, "file", "a.txt", []byte("not an image")) + rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) + if rec.Code != http.StatusBadRequest { + t.Fatalf("bad image = %d, want 400", rec.Code) + } + + // Replace changes the version segment so caches invalidate. + body, ct = multipartFile(t, "file", "b.png", img) + rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) + if rec.Code != http.StatusOK { + t.Fatalf("replace = %d body=%s", rec.Code, rec.Body.String()) + } + second := avatarURLOf(t, rec) + if second == "" || second == first { + t.Fatalf("avatar_url did not change on replace: %q", second) + } + + // Remove clears the avatar and the served URL 404s. + if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", token); rec.Code != http.StatusOK { + t.Fatalf("remove = %d body=%s", rec.Code, rec.Body.String()) + } else if url := avatarURLOf(t, rec); url != "" { + t.Fatalf("avatar_url not cleared: %q", url) + } + if rec := doAuthed(r, http.MethodGet, second, nil, "", token); rec.Code != http.StatusNotFound { + t.Fatalf("serve after remove = %d, want 404", rec.Code) + } +} + +func TestServeAvatarMissingUser(t *testing.T) { + r, token, _, _ := newAvatarServer(t) + rec := doAuthed(r, http.MethodGet, "/api/avatars/does-not-exist/x", nil, "", token) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestAvatarAppearsInUserViews(t *testing.T) { + ctx := context.Background() + r, token, uid, repos := newAvatarServer(t) + + body, ct := multipartFile(t, "file", "a.png", makeTestPNG(t)) + rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token) + if rec.Code != http.StatusOK { + t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String()) + } + url := avatarURLOf(t, rec) + + // The self profile carries the avatar. + rec = doAuthed(r, http.MethodGet, "/api/auth/me", nil, "", token) + if me := avatarURLOf(t, rec); me != url { + t.Fatalf("auth/me avatar_url = %q, want %q", me, url) + } + + // Public representations carry it too: a member with an avatar exposes the + // URL, a member without one omits it. + album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"} + if err := repos.Albums.Create(ctx, album); err != nil { + t.Fatalf("create album: %v", err) + } + if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil { + t.Fatalf("add owner member: %v", err) + } + other := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Other"} + if err := repos.Auth.CreateUser(ctx, other); err != nil { + t.Fatalf("create other user: %v", err) + } + if err := repos.Members.Add(ctx, album.ID, other.ID, "viewer", nil); err != nil { + t.Fatalf("add other member: %v", err) + } + + rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/members", nil, "", token) + if rec.Code != http.StatusOK { + t.Fatalf("members = %d body=%s", rec.Code, rec.Body.String()) + } + var mb struct { + Members []models.AlbumMember `json:"members"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &mb); err != nil { + t.Fatalf("decode members: %v", err) + } + seen := map[string]string{} + for _, m := range mb.Members { + seen[m.User.ID] = m.User.AvatarURL + } + if seen[uid] != url { + t.Fatalf("member avatar_url = %q, want %q", seen[uid], url) + } + if seen[other.ID] != "" { + t.Fatalf("member without avatar has avatar_url = %q", seen[other.ID]) + } + + // Comments carry the commenter's avatar too. + photo := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "p.jpg"} + obj := &photos.Object{ID: auth.NewID(), StorageKey: "objects/" + auth.NewID(), State: photos.ObjectPending} + if err := repos.Photos.CreateWithOriginal(ctx, photo, obj); err != nil { + t.Fatalf("create photo: %v", err) + } + if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil { + t.Fatalf("activate photo object: %v", err) + } + rec = doAuthed(r, http.MethodPost, "/api/photos/"+photo.ID+"/comments", bytes.NewBufferString(`{"text":"hi"}`), "application/json", token) + if rec.Code != http.StatusCreated { + t.Fatalf("comment = %d body=%s", rec.Code, rec.Body.String()) + } + rec = doAuthed(r, http.MethodGet, "/api/photos/"+photo.ID+"/comments", nil, "", token) + if rec.Code != http.StatusOK { + t.Fatalf("list comments = %d body=%s", rec.Code, rec.Body.String()) + } + var cb struct { + Comments []models.Comment `json:"comments"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &cb); err != nil { + t.Fatalf("decode comments: %v", err) + } + if len(cb.Comments) != 1 || cb.Comments[0].User.AvatarURL != url { + t.Fatalf("comment avatar_url = %+v, want %q", cb.Comments, url) + } +} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 27cb371..03b579a 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -19,6 +19,9 @@ type Repository interface { AddIdentifier(ctx context.Context, userID, email, phone string) error DeleteUser(ctx context.Context, userID string) error UpdatePreferences(ctx context.Context, userID string, raw []byte) error + // SetAvatarURL stores the stable URL of the user's avatar; an empty value + // clears it. + SetAvatarURL(ctx context.Context, userID, url string) error CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) RevokeRefreshToken(ctx context.Context, id string) error diff --git a/backend/internal/auth/sqlite_store.go b/backend/internal/auth/sqlite_store.go index a04b45d..df937bb 100644 --- a/backend/internal/auth/sqlite_store.go +++ b/backend/internal/auth/sqlite_store.go @@ -88,6 +88,13 @@ func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw `UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw))) } +// SetAvatarURL stores the stable URL of the user's avatar. An empty value +// clears the column. +func (s *SQLiteStore) SetAvatarURL(ctx context.Context, userID, url string) error { + return s.requireRow(s.db.ExecContext(ctx, + `UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url)) +} + func (s *SQLiteStore) requireRow(res sql.Result, err error) error { if err != nil { return err diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index a19ea38..b7df4ed 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -105,6 +105,14 @@ func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte return requireRow(tag, err) } +// SetAvatarURL stores the stable URL of the user's avatar. An empty value +// clears the column. +func (s *Store) SetAvatarURL(ctx context.Context, userID, url string) error { + tag, err := s.pool.Exec(ctx, + `UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url) + return requireRow(tag, err) +} + func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { _, err := s.pool.Exec(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) diff --git a/backend/internal/avatar/avatar.go b/backend/internal/avatar/avatar.go new file mode 100644 index 0000000..12af6ba --- /dev/null +++ b/backend/internal/avatar/avatar.go @@ -0,0 +1,56 @@ +// Package avatar manages a user's profile photo: it normalises an uploaded +// image into square WebP variants and keeps them as journaled objects bound to +// the user, so the object ledger's garbage collection preserves them. +package avatar + +import ( + "bytes" + "errors" + "image" + + "github.com/disintegration/imaging" + "github.com/gen2brain/webp" +) + +// Canonical avatar sizes. The small variant serves lists and headers; the large +// one serves the profile page. +const ( + SizeLarge = 512 + SizeSmall = 128 +) + +// maxUploadBytes caps an avatar upload. Avatars are small, so a generous +// general image limit would let a client stream needlessly. +const maxUploadBytes = 10 << 20 + +var errInvalidImage = errors.New("invalid image") + +// normalize decodes an uploaded image and produces two square WebP variants. +// Decoding honours the EXIF orientation; re-encoding discards all metadata +// (including GPS), so the stored avatar carries none of the original's. +func normalize(data []byte) (large, small []byte, err error) { + img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) + if err != nil { + return nil, nil, errInvalidImage + } + large, err = fillWebP(img, SizeLarge) + if err != nil { + return nil, nil, err + } + small, err = fillWebP(img, SizeSmall) + if err != nil { + return nil, nil, err + } + return large, small, nil +} + +// fillWebP centre-crops the image to a square of the given size and encodes it +// as WebP. +func fillWebP(img image.Image, size int) ([]byte, error) { + square := imaging.Fill(img, size, size, imaging.Center, imaging.Lanczos) + var buf bytes.Buffer + if err := webp.Encode(&buf, square, webp.Options{Quality: 82}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/backend/internal/avatar/handlers.go b/backend/internal/avatar/handlers.go new file mode 100644 index 0000000..6f85063 --- /dev/null +++ b/backend/internal/avatar/handlers.go @@ -0,0 +1,80 @@ +package avatar + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "galo/internal/auth" + "galo/internal/httpx" + "galo/internal/photos" +) + +// avatarCacheControl is safe to cache aggressively because the URL carries an +// opaque per-generation version segment: replacing the avatar changes the URL. +const avatarCacheControl = "private, max-age=31536000, immutable" + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +// Upload installs or replaces the authenticated user's avatar from a multipart +// "file" field and returns the updated self profile. +func (h *Handler) Upload(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.svc.MaxUploadBytes()) + fileHeader, err := c.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", + "avatar exceeds the maximum upload size")) + return + } + httpx.RespondError(c, httpx.BadRequest("invalid_request", "file is required")) + return + } + f, err := fileHeader.Open() + if err != nil { + httpx.RespondError(c, err) + return + } + defer f.Close() + + u, err := h.svc.SetAvatar(c.Request.Context(), auth.UserID(c), f) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": u.Self()}) +} + +// Remove resets the avatar to the default placeholder and returns the updated +// self profile. +func (h *Handler) Remove(c *gin.Context) { + u, err := h.svc.RemoveAvatar(c.Request.Context(), auth.UserID(c)) + if err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": u.Self()}) +} + +// Serve streams the user's avatar. The version segment only busts caches; the +// current generation is always served. +func (h *Handler) Serve(c *gin.Context) { + large := c.Query("size") == "large" + data, err := h.svc.AvatarImage(c.Request.Context(), c.Param("userID"), large) + if err != nil { + if errors.Is(err, photos.ErrObjectNotFound) { + httpx.RespondError(c, httpx.NotFound("avatar_not_found", "avatar not found")) + return + } + httpx.RespondError(c, err) + return + } + c.Header("Cache-Control", avatarCacheControl) + c.Data(http.StatusOK, "image/webp", data) +} diff --git a/backend/internal/avatar/normalize_test.go b/backend/internal/avatar/normalize_test.go new file mode 100644 index 0000000..e154657 --- /dev/null +++ b/backend/internal/avatar/normalize_test.go @@ -0,0 +1,89 @@ +package avatar + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/jpeg" + "testing" + + "github.com/gen2brain/webp" +) + +// jpegImage builds a non-square JPEG so the square centre crop is observable. +func jpegImage(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +// withEXIFSegment inserts an APP1 segment carrying an "Exif\x00\x00" marker. +// The payload stands in for real EXIF metadata (including GPS): the JPEG +// decoder ignores it, and re-encoding must drop it. +func withEXIFSegment(t *testing.T, jpegData []byte) []byte { + t.Helper() + payload := append([]byte("Exif\x00\x00"), 0x00, 0x00) // bogus TIFF header + segLen := len(payload) + 2 + seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen & 0xFF)} + seg = append(seg, payload...) + if len(jpegData) < 2 { + t.Fatalf("not a jpeg") + } + out := append([]byte{}, jpegData[:2]...) + out = append(out, seg...) + out = append(out, jpegData[2:]...) + return out +} + +func TestNormalizeSquaresAndStripsMetadata(t *testing.T) { + source := withEXIFSegment(t, jpegImage(t, 800, 400)) + if !bytes.Contains(source, []byte("Exif")) { + t.Fatalf("test setup: source has no EXIF marker") + } + + large, small, err := normalize(source) + if err != nil { + t.Fatalf("normalize: %v", err) + } + + cfg, err := webp.DecodeConfig(bytes.NewReader(large)) + if err != nil { + t.Fatalf("decode large: %v", err) + } + if cfg.Width != SizeLarge || cfg.Height != SizeLarge { + t.Fatalf("large = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeLarge, SizeLarge) + } + cfg, err = webp.DecodeConfig(bytes.NewReader(small)) + if err != nil { + t.Fatalf("decode small: %v", err) + } + if cfg.Width != SizeSmall || cfg.Height != SizeSmall { + t.Fatalf("small = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeSmall, SizeSmall) + } + + // Re-encoding must discard the source metadata. + for name, data := range map[string][]byte{"large": large, "small": small} { + if bytes.Contains(data, []byte("Exif")) { + t.Fatalf("%s WebP still contains an EXIF marker", name) + } + if _, err := webp.DecodeExif(bytes.NewReader(data)); !errors.Is(err, webp.ErrNoExif) { + t.Fatalf("%s metadata not stripped: %v", name, err) + } + } +} + +func TestNormalizeRejectsNonImage(t *testing.T) { + if _, _, err := normalize([]byte("definitely not an image")); !errors.Is(err, errInvalidImage) { + t.Fatalf("err = %v, want errInvalidImage", err) + } +} diff --git a/backend/internal/avatar/service.go b/backend/internal/avatar/service.go new file mode 100644 index 0000000..b2093d4 --- /dev/null +++ b/backend/internal/avatar/service.go @@ -0,0 +1,172 @@ +package avatar + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + + "galo/internal/auth" + "galo/internal/httpx" + "galo/internal/models" + "galo/internal/photos" + "galo/internal/storage" +) + +// ObjectStore is the slice of the object ledger the avatar service depends on. +// It is satisfied by the photos repository. +type ObjectStore interface { + CreateObject(ctx context.Context, o *photos.Object) error + ActivateObject(ctx context.Context, id string) error + DeleteObjectRow(ctx context.Context, id string) error + BindUserAvatar(ctx context.Context, userID, objectID, role string) error + UserAvatarObject(ctx context.Context, userID, role string) (*photos.Object, error) + ClearUserAvatar(ctx context.Context, userID string) error +} + +// UserStore reads the account and stores the avatar's stable URL. +type UserStore interface { + GetUserByID(ctx context.Context, id string) (*models.User, error) + SetAvatarURL(ctx context.Context, userID, url string) error +} + +// Service installs, replaces and removes a user's avatar. +type Service struct { + objects ObjectStore + users UserStore + storage storage.Backend + maxBytes int64 +} + +func NewService(objects ObjectStore, users UserStore, st storage.Backend) *Service { + return &Service{objects: objects, users: users, storage: st, maxBytes: maxUploadBytes} +} + +// SetMaxUploadBytes overrides the per-file avatar size limit. +func (s *Service) SetMaxUploadBytes(n int64) { + if n > 0 { + s.maxBytes = n + } +} + +// MaxUploadBytes returns the request body cap for an avatar upload. +func (s *Service) MaxUploadBytes() int64 { return s.maxBytes } + +// SetAvatar normalises the uploaded image, stores both variants as journaled +// objects and rebinds them to the user. The previous avatar's blobs become +// unreferenced and are reclaimed by garbage collection. +func (s *Service) SetAvatar(ctx context.Context, userID string, r io.Reader) (*models.User, error) { + data, err := io.ReadAll(io.LimitReader(r, s.maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > s.maxBytes { + return nil, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", + "avatar exceeds the maximum upload size") + } + large, small, err := normalize(data) + if err != nil { + return nil, httpx.BadRequest("invalid_image", "file is not a decodable image") + } + + largeObj, err := s.putObject(ctx, photos.RoleAvatar, SizeLarge, large) + if err != nil { + return nil, err + } + smallObj, err := s.putObject(ctx, photos.RoleAvatarSmall, SizeSmall, small) + if err != nil { + _ = s.objects.DeleteObjectRow(ctx, largeObj.ID) + return nil, err + } + + if err := s.objects.BindUserAvatar(ctx, userID, largeObj.ID, photos.RoleAvatar); err != nil { + s.rollback(ctx, userID, false, largeObj, smallObj) + return nil, err + } + if err := s.objects.BindUserAvatar(ctx, userID, smallObj.ID, photos.RoleAvatarSmall); err != nil { + s.rollback(ctx, userID, true, largeObj, smallObj) + return nil, err + } + if err := s.users.SetAvatarURL(ctx, userID, avatarURL(userID, largeObj.ID)); err != nil { + s.rollback(ctx, userID, true, largeObj, smallObj) + return nil, err + } + return s.users.GetUserByID(ctx, userID) +} + +// RemoveAvatar clears the binding and the stored URL. The blobs are reclaimed +// by garbage collection. +func (s *Service) RemoveAvatar(ctx context.Context, userID string) (*models.User, error) { + if err := s.objects.ClearUserAvatar(ctx, userID); err != nil { + return nil, err + } + if err := s.users.SetAvatarURL(ctx, userID, ""); err != nil { + return nil, err + } + return s.users.GetUserByID(ctx, userID) +} + +// AvatarImage returns the stored bytes of the user's avatar for the requested +// variant. It reports photos.ErrObjectNotFound when the user has no avatar. +func (s *Service) AvatarImage(ctx context.Context, userID string, large bool) ([]byte, error) { + role := photos.RoleAvatarSmall + if large { + role = photos.RoleAvatar + } + obj, err := s.objects.UserAvatarObject(ctx, userID, role) + if err != nil { + return nil, err + } + return s.storage.Get(ctx, obj.StorageKey) +} + +// putObject records a pending object row, stores the bytes and activates it. A +// failure after the row exists removes it so no orphan is left behind. +func (s *Service) putObject(ctx context.Context, role string, size int, data []byte) (*photos.Object, error) { + id := auth.NewID() + key := photos.ObjectKey(id, role, ".webp") + obj := &photos.Object{ + ID: id, + StorageKey: key, + MimeType: "image/webp", + Size: int64(len(data)), + Width: size, + Height: size, + Checksum: checksum(data), + } + if err := s.objects.CreateObject(ctx, obj); err != nil { + return nil, err + } + if err := s.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/webp"); err != nil { + _ = s.objects.DeleteObjectRow(ctx, id) + return nil, err + } + if err := s.objects.ActivateObject(ctx, id); err != nil { + _ = s.objects.DeleteObjectRow(ctx, id) + return nil, err + } + return obj, nil +} + +// rollback discards freshly created objects after a failed install. When a new +// binding was already written it is cleared first, so the object rows can be +// removed without violating the binding's foreign key. +func (s *Service) rollback(ctx context.Context, userID string, bound bool, objs ...*photos.Object) { + if bound { + _ = s.objects.ClearUserAvatar(ctx, userID) + } + for _, o := range objs { + _ = s.objects.DeleteObjectRow(ctx, o.ID) + } +} + +func avatarURL(userID, version string) string { + return "/api/avatars/" + userID + "/" + version +} + +func checksum(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/avatar/service_test.go b/backend/internal/avatar/service_test.go new file mode 100644 index 0000000..d423a32 --- /dev/null +++ b/backend/internal/avatar/service_test.go @@ -0,0 +1,223 @@ +package avatar + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "path/filepath" + "testing" + "time" + + assets "galo" + "galo/internal/auth" + "galo/internal/db" + "galo/internal/httpx" + "galo/internal/models" + "galo/internal/photos" + "galo/internal/storage" + "galo/internal/store" +) + +type stack struct { + svc *Service + repos store.Repos + blob storage.Backend + userID string +} + +func newStack(t *testing.T) *stack { + t.Helper() + migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") + if err != nil { + t.Fatalf("migrations fs: %v", err) + } + path := filepath.Join(t.TempDir(), "meta.db") + if err := db.MigrateSQLite(path, migFS); err != nil { + t.Fatalf("migrate: %v", err) + } + database, err := db.OpenSQLite(path) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + repos := store.NewSQLite(database) + signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute) + if err != nil { + t.Fatalf("signer: %v", err) + } + blob, err := storage.NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("fs: %v", err) + } + + u := &models.User{ID: auth.NewID(), Email: auth.NewID()[:10] + "@test.local", PasswordHash: "x", Name: "Tester"} + if err := repos.Auth.CreateUser(context.Background(), u); err != nil { + t.Fatalf("create user: %v", err) + } + + return &stack{svc: NewService(repos.Photos, repos.Auth, blob), repos: repos, blob: blob, userID: u.ID} +} + +func (s *stack) objectKeys(t *testing.T) int { + t.Helper() + keys, err := s.repos.Photos.AllStorageKeys(context.Background()) + if err != nil { + t.Fatalf("all storage keys: %v", err) + } + return len(keys) +} + +func errCode(t *testing.T, err error) string { + t.Helper() + var appErr *httpx.AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %v", err) + } + return appErr.Code +} + +func TestSetAvatarBindsBothVariants(t *testing.T) { + ctx := context.Background() + st := newStack(t) + before := st.objectKeys(t) + + updated, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 400, 400))) + if err != nil { + t.Fatalf("set avatar: %v", err) + } + if updated.AvatarURL == "" || !bytes.Contains([]byte(updated.AvatarURL), []byte(st.userID)) { + t.Fatalf("avatar_url not set: %q", updated.AvatarURL) + } + if st.objectKeys(t) != before+2 { + t.Fatalf("object count = %d, want %d", st.objectKeys(t), before+2) + } + + large, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) + if err != nil || large.Width != SizeLarge || large.Height != SizeLarge { + t.Fatalf("large variant: %v %+v", err, large) + } + small, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall) + if err != nil || small.Width != SizeSmall || small.Height != SizeSmall { + t.Fatalf("small variant: %v %+v", err, small) + } + + data, err := st.svc.AvatarImage(ctx, st.userID, false) + if err != nil || len(data) == 0 { + t.Fatalf("serve small: %v len=%d", err, len(data)) + } +} + +func TestSetAvatarReplacementReclaimsOldObjects(t *testing.T) { + ctx := context.Background() + st := newStack(t) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 300, 300))); err != nil { + t.Fatalf("first avatar: %v", err) + } + oldLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) + oldSmall, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 200, 200))); err != nil { + t.Fatalf("replace avatar: %v", err) + } + + claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10) + if err != nil { + t.Fatalf("claim garbage: %v", err) + } + got := map[string]bool{} + for _, o := range claimed { + got[o.ID] = true + } + if !got[oldLarge.ID] || !got[oldSmall.ID] { + t.Fatalf("superseded objects not collected: %+v", got) + } + + // The current avatar keeps its objects. + newLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) + if got[newLarge.ID] { + t.Fatalf("current avatar object was collected") + } +} + +func TestRemoveAvatarClearsAndReclaims(t *testing.T) { + ctx := context.Background() + st := newStack(t) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err != nil { + t.Fatalf("set avatar: %v", err) + } + large, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) + + updated, err := st.svc.RemoveAvatar(ctx, st.userID) + if err != nil { + t.Fatalf("remove avatar: %v", err) + } + if updated.AvatarURL != "" { + t.Fatalf("avatar_url not cleared: %q", updated.AvatarURL) + } + if _, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) { + t.Fatalf("avatar still bound: %v", err) + } + claimed, _ := st.repos.Photos.ClaimGarbage(ctx, 10) + found := false + for _, o := range claimed { + if o.ID == large.ID { + found = true + } + } + if !found { + t.Fatalf("removed avatar object not collected: %+v", claimed) + } +} + +func TestSetAvatarRejectsNonImage(t *testing.T) { + ctx := context.Background() + st := newStack(t) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader([]byte("not an image"))); errCode(t, err) != "invalid_image" { + t.Fatalf("code = %v, want invalid_image", err) + } + if st.objectKeys(t) != 0 { + t.Fatalf("objects created for an invalid image") + } +} + +func TestSetAvatarRejectsOversize(t *testing.T) { + ctx := context.Background() + st := newStack(t) + st.svc.SetMaxUploadBytes(1024) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(make([]byte, 2048))); errCode(t, err) != "file_too_large" { + t.Fatalf("code = %v, want file_too_large", err) + } + if st.objectKeys(t) != 0 { + t.Fatalf("objects created for an oversize upload") + } +} + +// failingStorage delegates to a real backend but fails every Put, to exercise +// the rollback that must not leave orphaned object rows. +type failingStorage struct { + storage.Backend +} + +func (failingStorage) Put(context.Context, string, io.Reader, int64, string) error { + return errors.New("put failed") +} + +func TestSetAvatarStorageFailureLeavesNoObjects(t *testing.T) { + ctx := context.Background() + st := newStack(t) + st.svc = NewService(st.repos.Photos, st.repos.Auth, failingStorage{st.blob}) + + if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err == nil { + t.Fatalf("expected storage failure") + } + if st.objectKeys(t) != 0 { + t.Fatalf("orphaned object rows after storage failure") + } +} diff --git a/backend/internal/models/models_test.go b/backend/internal/models/models_test.go index 5f91f6a..2b487fe 100644 --- a/backend/internal/models/models_test.go +++ b/backend/internal/models/models_test.go @@ -28,3 +28,28 @@ func TestPublicUserOmitsPhone(t *testing.T) { t.Fatalf("SelfUser phone not serialized: %s", self) } } + +func TestPublicUserCarriesAvatar(t *testing.T) { + u := &User{ID: "u1", Name: "Tester", AvatarURL: "/api/avatars/u1/v123"} + + pub, err := json.Marshal(u.Public()) + if err != nil { + t.Fatalf("marshal public: %v", err) + } + if !strings.Contains(string(pub), `"avatar_url":"/api/avatars/u1/v123"`) { + t.Fatalf("PublicUser avatar_url missing: %s", pub) + } + self, err := json.Marshal(u.Self()) + if err != nil { + t.Fatalf("marshal self: %v", err) + } + if !strings.Contains(string(self), `"avatar_url":"/api/avatars/u1/v123"`) { + t.Fatalf("SelfUser avatar_url missing: %s", self) + } + + // Without an avatar the field is omitted, so clients fall back to a placeholder. + bare, _ := json.Marshal((&User{ID: "u2", Name: "Bare"}).Public()) + if strings.Contains(string(bare), "avatar_url") { + t.Fatalf("empty avatar_url should be omitted: %s", bare) + } +} diff --git a/backend/internal/photos/keys.go b/backend/internal/photos/keys.go index 39f7c6a..59214f0 100644 --- a/backend/internal/photos/keys.go +++ b/backend/internal/photos/keys.go @@ -15,3 +15,8 @@ func objectKey(objectID, role, ext string) string { } return fmt.Sprintf("objects/%s/%s%s", objectID, role, ext) } + +// ObjectKey exposes the opaque storage-key format for callers outside the +// photos package that need to create their own journaled objects (for example +// user avatars). The key stays entity-free; only the caller knows the binding. +func ObjectKey(objectID, role, ext string) string { return objectKey(objectID, role, ext) } diff --git a/backend/internal/photos/objects.go b/backend/internal/photos/objects.go index f1a7ae8..58313df 100644 --- a/backend/internal/photos/objects.go +++ b/backend/internal/photos/objects.go @@ -25,6 +25,13 @@ const ( RolePlayback = "playback" ) +// Avatar roles as bound to a user. The small variant serves lists and headers; +// the large one serves the profile page. +const ( + RoleAvatar = "avatar" + RoleAvatarSmall = "avatar_small" +) + var ( // ErrObjectNotFound is returned when an object row does not exist. ErrObjectNotFound = errors.New("object not found") @@ -120,6 +127,60 @@ func (s *Store) BindObject(ctx context.Context, photoID, objectID, role string) return tx.Commit(ctx) } +// BindUserAvatar attaches a ready object as the user's avatar under the given +// role. It mirrors BindObject: the object row is locked and refused unless it is +// ready, so an object being garbage-collected can never be bound. +func (s *Store) BindUserAvatar(ctx context.Context, userID, objectID, role string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + var state string + if err := tx.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1 FOR UPDATE`, objectID).Scan(&state); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrObjectNotFound + } + return err + } + if state != ObjectReady { + return ErrObjectNotReady + } + if _, err := tx.Exec(ctx, + `INSERT INTO user_avatar_objects (user_id, object_id, role) VALUES ($1, $2, $3) + ON CONFLICT (user_id, role) DO UPDATE SET object_id = EXCLUDED.object_id`, + userID, objectID, role); err != nil { + return err + } + return tx.Commit(ctx) +} + +// UserAvatarObject returns the object currently bound as the user's avatar for +// the given role. +func (s *Store) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) { + var o Object + err := s.pool.QueryRow(ctx, + `SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at + FROM user_avatar_objects uao JOIN objects o ON o.id = uao.object_id + WHERE uao.user_id = $1 AND uao.role = $2`, userID, role). + Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &o.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrObjectNotFound + } + return nil, err + } + return &o, nil +} + +// ClearUserAvatar removes every avatar binding of the user. The blobs become +// unreferenced and are reclaimed by garbage collection. +func (s *Store) ClearUserAvatar(ctx context.Context, userID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM user_avatar_objects WHERE user_id = $1`, userID) + return err +} + // PhotoObjectIDs returns the object ids currently bound to a photo. func (s *Store) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) { rows, err := s.pool.Query(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID) @@ -199,6 +260,7 @@ func (s *Store) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) { `SELECT o.id, o.storage_key FROM objects o WHERE o.state = ANY($1) AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id) + AND NOT EXISTS (SELECT 1 FROM user_avatar_objects uao WHERE uao.object_id = o.id) ORDER BY o.created_at LIMIT $2 FOR UPDATE SKIP LOCKED`, []string{ObjectReady, ObjectDeleting}, limit) diff --git a/backend/internal/photos/repository.go b/backend/internal/photos/repository.go index ae5599d..6c213e9 100644 --- a/backend/internal/photos/repository.go +++ b/backend/internal/photos/repository.go @@ -49,6 +49,11 @@ type Repository interface { MarkObjectDeleting(ctx context.Context, id string) error DeleteObjectRow(ctx context.Context, id string) error BindObject(ctx context.Context, photoID, objectID, role string) error + // BindUserAvatar, UserAvatarObject and ClearUserAvatar manage the object + // binding that backs a user's profile avatar. + BindUserAvatar(ctx context.Context, userID, objectID, role string) error + UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) + ClearUserAvatar(ctx context.Context, userID string) error PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) ObjectRefCount(ctx context.Context, objectID string) (int, error) OriginalObject(ctx context.Context, photoID string) (*Object, error) diff --git a/backend/internal/photos/sqlite_objects.go b/backend/internal/photos/sqlite_objects.go index c85a80f..5cb00d0 100644 --- a/backend/internal/photos/sqlite_objects.go +++ b/backend/internal/photos/sqlite_objects.go @@ -89,6 +89,62 @@ func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role st return tx.Commit() } +// BindUserAvatar attaches a ready object as the user's avatar under the given +// role. The write lock of the immediate transaction serializes it against +// garbage collection. +func (s *SQLiteStore) BindUserAvatar(ctx context.Context, userID, objectID, role string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var state string + if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrObjectNotFound + } + return err + } + if state != ObjectReady { + return ErrObjectNotReady + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO user_avatar_objects (user_id, object_id, role) VALUES ($1, $2, $3) + ON CONFLICT (user_id, role) DO UPDATE SET object_id = excluded.object_id`, + userID, objectID, role); err != nil { + return err + } + return tx.Commit() +} + +// UserAvatarObject returns the object currently bound as the user's avatar for +// the given role. +func (s *SQLiteStore) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) { + var o Object + var createdAt string + err := s.db.QueryRowContext(ctx, + `SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at + FROM user_avatar_objects uao JOIN objects o ON o.id = uao.object_id + WHERE uao.user_id = $1 AND uao.role = $2`, userID, role). + Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrObjectNotFound + } + return nil, err + } + o.CreatedAt = sqliteutil.ParseTime(createdAt) + return &o, nil +} + +// ClearUserAvatar removes every avatar binding of the user. The blobs become +// unreferenced and are reclaimed by garbage collection. +func (s *SQLiteStore) ClearUserAvatar(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM user_avatar_objects WHERE user_id = $1`, userID) + return err +} + // PhotoObjectIDs returns the object ids currently bound to a photo. func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID) @@ -169,6 +225,7 @@ func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, er `SELECT o.id, o.storage_key FROM objects o WHERE o.state IN ('ready', 'deleting') AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id) + AND NOT EXISTS (SELECT 1 FROM user_avatar_objects uao WHERE uao.object_id = o.id) ORDER BY o.created_at LIMIT $1`, limit) if err != nil { diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 887416a..ca0cf29 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) { {"Social", testSocial}, {"Photos", testPhotos}, {"Objects", testObjects}, + {"UserAvatar", testUserAvatar}, {"VariantJobKinds", testVariantJobKinds}, {"Settings", testSettings}, } @@ -648,6 +649,79 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { } } +func testUserAvatar(t *testing.T, ctx context.Context, r Repos) { + owner := seedUser(t, ctx, r, "Owner") + + obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} + if err := r.Photos.CreateObject(ctx, obj); err != nil { + t.Fatalf("create avatar object: %v", err) + } + if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotReady) { + t.Fatalf("bind pending avatar err = %v, want ErrObjectNotReady", err) + } + if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil { + t.Fatalf("activate avatar object: %v", err) + } + if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); err != nil { + t.Fatalf("bind avatar: %v", err) + } + + got, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar) + if err != nil || got.ID != obj.ID || got.StorageKey != obj.StorageKey { + t.Fatalf("avatar object: %v %+v", err, got) + } + // Roles are independent: no small variant was bound. + if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatarSmall); !errors.Is(err, photos.ErrObjectNotFound) { + t.Fatalf("unbound small role err = %v, want ErrObjectNotFound", err) + } + + // A bound avatar blob survives garbage collection. + claimed, err := r.Photos.ClaimGarbage(ctx, 10) + if err != nil { + t.Fatalf("claim garbage: %v", err) + } + if containsObject(claimed, obj.ID) { + t.Fatalf("avatar object collected while bound: %+v", claimed) + } + + // Replacing the binding (a new generation) makes the old blob collectible. + replacement := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} + if err := r.Photos.CreateObject(ctx, replacement); err != nil { + t.Fatalf("create replacement: %v", err) + } + if err := r.Photos.ActivateObject(ctx, replacement.ID); err != nil { + t.Fatalf("activate replacement: %v", err) + } + if err := r.Photos.BindUserAvatar(ctx, owner.ID, replacement.ID, photos.RoleAvatar); err != nil { + t.Fatalf("rebind avatar: %v", err) + } + claimed, _ = r.Photos.ClaimGarbage(ctx, 10) + if !containsObject(claimed, obj.ID) { + t.Fatalf("superseded avatar object not collected: %+v", claimed) + } + + // Clearing removes every binding so the current blob is collected too. + if err := r.Photos.ClearUserAvatar(ctx, owner.ID); err != nil { + t.Fatalf("clear avatar: %v", err) + } + if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) { + t.Fatalf("avatar still bound after clear: %v", err) + } + claimed, _ = r.Photos.ClaimGarbage(ctx, 10) + if !containsObject(claimed, replacement.ID) { + t.Fatalf("cleared avatar object not collected: %+v", claimed) + } +} + +func containsObject(list []photos.Object, id string) bool { + for _, o := range list { + if o.ID == id { + return true + } + } + return false +} + func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) diff --git a/backend/internal/repo/contract/sqlite_test.go b/backend/internal/repo/contract/sqlite_test.go index 1873d69..bd43b1f 100644 --- a/backend/internal/repo/contract/sqlite_test.go +++ b/backend/internal/repo/contract/sqlite_test.go @@ -44,7 +44,7 @@ func TestSQLiteContract(t *testing.T) { Settings: settings.NewSQLiteStore(database), Reset: func(ctx context.Context) error { tables := []string{ - "variant_jobs", "photo_objects", "objects", "photo_tags", "tags", + "variant_jobs", "photo_objects", "user_avatar_objects", "objects", "photo_tags", "tags", "refresh_tokens", "share_links", "notifications", "likes", "comments", "invites", "album_members", "photos", "albums", "users", "app_settings", } diff --git a/backend/migrations/000014_user_avatar_objects.down.sql b/backend/migrations/000014_user_avatar_objects.down.sql new file mode 100644 index 0000000..5a370d3 --- /dev/null +++ b/backend/migrations/000014_user_avatar_objects.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_avatar_objects; diff --git a/backend/migrations/000014_user_avatar_objects.up.sql b/backend/migrations/000014_user_avatar_objects.up.sql new file mode 100644 index 0000000..c84611c --- /dev/null +++ b/backend/migrations/000014_user_avatar_objects.up.sql @@ -0,0 +1,12 @@ +-- A user's avatar is stored as journaled objects bound by role, mirroring +-- photo_objects. Keeping the binding in the ledger means garbage collection +-- preserves avatar blobs while they are referenced and reclaims them once the +-- avatar is replaced or removed. +CREATE TABLE IF NOT EXISTS user_avatar_objects ( + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + object_id text NOT NULL REFERENCES objects(id) ON DELETE RESTRICT, + role text NOT NULL CHECK (role IN ('avatar', 'avatar_small')), + PRIMARY KEY (user_id, role) +); + +CREATE INDEX IF NOT EXISTS user_avatar_objects_object_idx ON user_avatar_objects (object_id); diff --git a/backend/migrations/sqlite/000006_user_avatar_objects.down.sql b/backend/migrations/sqlite/000006_user_avatar_objects.down.sql new file mode 100644 index 0000000..5a370d3 --- /dev/null +++ b/backend/migrations/sqlite/000006_user_avatar_objects.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_avatar_objects; diff --git a/backend/migrations/sqlite/000006_user_avatar_objects.up.sql b/backend/migrations/sqlite/000006_user_avatar_objects.up.sql new file mode 100644 index 0000000..3dbdfbc --- /dev/null +++ b/backend/migrations/sqlite/000006_user_avatar_objects.up.sql @@ -0,0 +1,12 @@ +-- A user's avatar is stored as journaled objects bound by role, mirroring +-- photo_objects. Keeping the binding in the ledger means garbage collection +-- preserves avatar blobs while they are referenced and reclaims them once the +-- avatar is replaced or removed. +CREATE TABLE user_avatar_objects ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT, + role TEXT NOT NULL CHECK (role IN ('avatar', 'avatar_small')), + PRIMARY KEY (user_id, role) +); + +CREATE INDEX IF NOT EXISTS user_avatar_objects_object_idx ON user_avatar_objects (object_id); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0d23e97..a6e0470 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -101,6 +101,12 @@ export const authApi = { me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'), updateProfile: (name: string) => api<{ user: User }>('/auth/me', { method: 'PATCH', body: { name } }), + uploadAvatar: (file: Blob) => { + const form = new FormData(); + form.append('file', file, 'avatar.jpg'); + return api<{ user: User }>('/auth/me/avatar', { method: 'POST', form }); + }, + removeAvatar: () => api<{ user: User }>('/auth/me/avatar', { method: 'DELETE' }), changePassword: (current_password: string, new_password: string) => api('/auth/me/password', { method: 'POST', body: { current_password, new_password } }), addIdentifier: (email: string, phone: string) => diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 40b14c8..082535a 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { authApi } from '$lib/api'; + import Avatar from './Avatar.svelte'; import Emblem from './Emblem.svelte'; import Icon from './Icon.svelte'; import ThemeSwitcher from './ThemeSwitcher.svelte'; @@ -135,10 +136,10 @@ {#if auth.isAuthenticated} - + + + + + diff --git a/frontend/src/lib/components/Comments.svelte b/frontend/src/lib/components/Comments.svelte index 41a51bc..65aa1b8 100644 --- a/frontend/src/lib/components/Comments.svelte +++ b/frontend/src/lib/components/Comments.svelte @@ -1,5 +1,6 @@