feat(profile): add round avatar with upload and cropping

- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs,
  avatar service (EXIF-stripping WebP 128/512), public avatar read route
- web: avatar upload/crop on profile, round avatar in header/members/comments/author
- android: avatar upload/crop, round avatar on profile and albums header
- openspec: add-user-avatar proposal/design/specs/tasks
This commit is contained in:
iu
2026-10-12 16:16:47 +03:00
parent 657a04309e
commit 04617d1cdb
49 changed files with 2362 additions and 21 deletions
+2
View File
@@ -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`
@@ -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
@@ -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 = "Загрузки")
@@ -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<String?>(null)
val state: StateFlow<UiState> = 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) {
@@ -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),
)
}
}
}
@@ -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()
}
}
@@ -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<ImageBitmap?>(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(
@@ -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) {
@@ -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()
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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<String, String>? = 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)
}
}
+14
View File
@@ -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 <img> 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)
+277
View File
@@ -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)
}
}
+3
View File
@@ -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
+7
View File
@@ -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
+8
View File
@@ -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)
+56
View File
@@ -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
}
+80
View File
@@ -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)
}
+89
View File
@@ -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)
}
}
+172
View File
@@ -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[:])
}
+223
View File
@@ -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")
}
}
+25
View File
@@ -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)
}
}
+5
View File
@@ -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) }
+62
View File
@@ -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)
+5
View File
@@ -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)
+57
View File
@@ -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 {
@@ -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)
@@ -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",
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_avatar_objects;
@@ -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);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_avatar_objects;
@@ -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);
+6
View File
@@ -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) =>
+3 -2
View File
@@ -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}
<a
href="/profile"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-xs"
title="Профиль"
>
<Icon name="settings" class="size-4" />
<Avatar name={auth.user?.name} url={auth.user?.avatar_url} size="size-6" />
<span class="hidden sm:inline">{auth.user?.name ?? 'Профиль'}</span>
</a>
<button
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import Icon from './Icon.svelte';
interface Props {
name?: string;
url?: string | null;
/** Tailwind size classes for the round frame, e.g. "size-6". */
size?: string;
class?: string;
}
let { name = '', url = '', size = 'size-6', class: className = '' }: Props = $props();
</script>
{#if url}
<img
src={url}
alt={name || 'Аватар'}
title={name}
class="{size} {className} shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="{size} {className} bg-bg-muted text-text-muted inline-flex shrink-0 items-center justify-center rounded-full"
role="img"
aria-label={name || 'Аватар'}
title={name}
>
<Icon name="person" class="h-3/4 w-3/4" />
</span>
{/if}
@@ -0,0 +1,191 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
interface Props {
file: File;
onCancel: () => void;
onConfirm: (blob: Blob) => void;
}
let { file, onCancel, onConfirm }: Props = $props();
// The cropper always produces a square. VIEW is the on-screen picker size;
// OUTPUT is the uploaded resolution.
const VIEW = 320;
const OUTPUT = 512;
let canvas: HTMLCanvasElement | null = null;
let img = $state<HTMLImageElement | null>(null);
let zoom = $state(1);
let offsetX = $state(0);
let offsetY = $state(0);
let dragging = false;
let lastX = 0;
let lastY = 0;
let busy = $state(false);
onMount(() => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
img = image;
clamp();
};
image.src = url;
return () => URL.revokeObjectURL(url);
});
function baseScale(): number {
if (!img) return 1;
return Math.max(VIEW / img.naturalWidth, VIEW / img.naturalHeight);
}
function scale(): number {
return baseScale() * zoom;
}
function drawn(): { w: number; h: number } {
if (!img) return { w: VIEW, h: VIEW };
const s = scale();
return { w: img.naturalWidth * s, h: img.naturalHeight * s };
}
function clamp() {
const { w, h } = drawn();
const mx = Math.max(0, (w - VIEW) / 2);
const my = Math.max(0, (h - VIEW) / 2);
offsetX = Math.min(mx, Math.max(-mx, offsetX));
offsetY = Math.min(my, Math.max(-my, offsetY));
}
function draw() {
if (!canvas || !img) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { w, h } = drawn();
const x = (VIEW - w) / 2 + offsetX;
const y = (VIEW - h) / 2 + offsetY;
ctx.clearRect(0, 0, VIEW, VIEW);
ctx.drawImage(img, x, y, w, h);
// Dim everything outside the circular crop.
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, VIEW, VIEW);
ctx.arc(VIEW / 2, VIEW / 2, VIEW / 2 - 1, 0, Math.PI * 2, true);
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fill('evenodd');
ctx.restore();
ctx.beginPath();
ctx.arc(VIEW / 2, VIEW / 2, VIEW / 2 - 1, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.stroke();
}
$effect(() => {
img;
zoom;
offsetX;
offsetY;
draw();
});
function onPointerDown(e: PointerEvent) {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
offsetX += e.clientX - lastX;
offsetY += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
clamp();
}
function onPointerUp(e: PointerEvent) {
dragging = false;
try {
(e.currentTarget as HTMLCanvasElement).releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
}
function onZoom(e: Event) {
zoom = Number((e.currentTarget as HTMLInputElement).value);
clamp();
}
async function confirm() {
if (!img || !canvas) return;
busy = true;
try {
const s = scale();
const side = VIEW / s;
const cx = img.naturalWidth / 2 - offsetX / s;
const cy = img.naturalHeight / 2 - offsetY / s;
const out = document.createElement('canvas');
out.width = OUTPUT;
out.height = OUTPUT;
const octx = out.getContext('2d');
if (!octx) return;
octx.drawImage(img, cx - side / 2, cy - side / 2, side, side, 0, 0, OUTPUT, OUTPUT);
const blob = await new Promise<Blob | null>((resolve) =>
out.toBlob(resolve, 'image/jpeg', 0.9)
);
if (blob) onConfirm(blob);
} finally {
busy = false;
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
role="dialog"
aria-modal="true"
aria-label="Кадрирование аватара"
>
<div class="border-border bg-bg-elevated w-full max-w-sm rounded-xl border p-4">
<h2 class="mb-3 font-semibold">Кадрирование</h2>
<canvas
bind:this={canvas}
width={VIEW}
height={VIEW}
class="bg-bg mx-auto block touch-none rounded-lg"
style="width: {VIEW}px; height: {VIEW}px;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
></canvas>
<label class="mt-3 block">
<span class="text-text-muted mb-1 block text-xs">Масштаб</span>
<input type="range" min="1" max="3" step="0.01" value={zoom} oninput={onZoom} class="w-full" />
</label>
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={onCancel}
>
<Icon name="close" class="size-4" />
Отмена
</button>
<button
type="button"
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
disabled={busy}
onclick={confirm}
>
<Icon name="check" class="size-4" />
Сохранить
</button>
</div>
</div>
</div>
+4 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import Avatar from './Avatar.svelte';
import Icon from './Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Comment } from '$lib/types';
@@ -56,8 +57,9 @@
<div class="mt-2">
{#each comments as comment (comment.id)}
<div class="border-border mb-1 flex items-start justify-between gap-2 border-b py-1 text-sm">
<span class="text-text">
<strong>{comment.user.name}</strong>: {comment.text}
<span class="text-text inline-flex items-start gap-2">
<Avatar name={comment.user.name} url={comment.user.avatar_url} size="size-6" />
<span><strong>{comment.user.name}</strong>: {comment.text}</span>
</span>
{#if auth.user?.id === comment.user_id}
<button
@@ -1,5 +1,6 @@
<script lang="ts">
import { api, photosApi, tagsApi } from '$lib/api';
import Avatar from './Avatar.svelte';
import Icon from './Icon.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
@@ -396,7 +397,7 @@
</button>
{/snippet}
<dl class="space-y-1">
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
<div class={metaRow}><span>Автор</span><span class="text-text inline-flex items-center gap-2"><Avatar name={photo.uploader.name} url={photo.uploader.avatar_url} size="size-6" />{photo.uploader.name}</span></div>
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
<div class={metaRow}>
+1
View File
@@ -23,6 +23,7 @@ export const icons = {
play_arrow: { path: '<path fill="currentColor" d="M8 19V5l11 7z"/>' },
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
person: { path: '<path fill="currentColor" d="M12 12q-1.65 0-2.825-1.175T8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12m-8 8v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T20 17.2V20z"/>' },
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
cloud_upload: { path: '<path fill="currentColor" d="M11 20H6.5q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 1.875-1.312 3.188T18.5 20H13v-7.15l1.6 1.55L16 13l-4-4l-4 4l1.4 1.4l1.6-1.55z"/>' },
add_photo_alternate: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h9q-.5.65-.75 1.425T13 6q0 2.075 1.463 3.538T18 11q.8 0 1.575-.25T21 10v9q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13zm11-8V7h-2V5h2V3h2v2h2v2h-2v2z"/>' },
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import Avatar from '$lib/components/Avatar.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, AlbumMember } from '$lib/types';
@@ -101,8 +102,10 @@
</li>
{#each members as member (member.user_id)}
<li class="flex items-center justify-between gap-4 px-4 py-3">
<span class="text-text"
>{member.user.name} <span class="text-text-muted text-sm">({member.user.email})</span></span
<span class="text-text inline-flex items-center gap-2"
><Avatar name={member.user.name} url={member.user.avatar_url} size="size-7" />
{member.user.name}
<span class="text-text-muted text-sm">({member.user.email})</span></span
>
<div class="flex items-center gap-2">
{#if assignableRoles.length > 0}
+85 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Avatar from '$lib/components/Avatar.svelte';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
@@ -11,6 +13,8 @@
let currentPassword = $state('');
let newPassword = $state('');
let deletePassword = $state('');
let pickedFile = $state<File | null>(null);
let avatarBusy = $state(false);
let error = $state('');
let notice = $state('');
@@ -34,6 +38,44 @@
if (updated) auth.setUser(updated);
}
function onPickFile(ev: Event) {
const input = ev.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) pickedFile = file;
}
async function onCropConfirm(blob: Blob) {
avatarBusy = true;
error = '';
notice = '';
try {
const data = await authApi.uploadAvatar(blob);
applyUser(data.user);
pickedFile = null;
notice = 'Аватар обновлён';
} catch (e) {
error = (e as Error).message;
} finally {
avatarBusy = false;
}
}
async function removeAvatar() {
avatarBusy = true;
error = '';
notice = '';
try {
const data = await authApi.removeAvatar();
applyUser(data.user);
notice = 'Аватар удалён';
} catch (e) {
error = (e as Error).message;
} finally {
avatarBusy = false;
}
}
async function saveName(ev: Event) {
ev.preventDefault();
error = '';
@@ -75,8 +117,7 @@
}
}
async function removeAccount() {
error = '';
async function removeAccount() { error = '';
if (!confirm('Удалить аккаунт со всем содержимым? Действие необратимо.')) return;
try {
await authApi.deleteAccount(deletePassword);
@@ -99,6 +140,40 @@
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
{#if notice}<p class="text-success mb-4 text-sm">{notice}</p>{/if}
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-3 font-semibold">Фото профиля</h2>
<div class="flex items-center gap-4">
<Avatar name={user?.name} url={user?.avatar_url} size="size-20" />
<div class="flex flex-wrap gap-2">
<label
class="bg-primary text-primary-fg inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
>
<Icon name="photo_camera" class="size-4" />
{user?.avatar_url ? 'Заменить фото' : 'Загрузить фото'}
<input
type="file"
accept="image/*"
class="hidden"
disabled={avatarBusy}
onchange={onPickFile}
/>
</label>
{#if user?.avatar_url}
<button
type="button"
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm disabled:opacity-50"
disabled={avatarBusy}
onclick={removeAvatar}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{/if}
</div>
</div>
<p class="text-text-muted mt-2 text-xs">Квадратное изображение; можно выбрать область кадрирования.</p>
</div>
<div class="border-border bg-bg-elevated mb-6 rounded-xl border p-4">
<h2 class="mb-2 font-semibold">Идентификаторы</h2>
<ul class="text-text-muted space-y-1 text-sm">
@@ -224,3 +299,11 @@
Удалить аккаунт
</button>
</div>
{#if pickedFile}
<AvatarCropper
file={pickedFile}
onCancel={() => (pickedFile = null)}
onConfirm={onCropConfirm}
/>
{/if}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12
+119
View File
@@ -0,0 +1,119 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- `users.avatar_url` (text) существует в Postgres и SQLite с миграции `000001`; поля
`User.AvatarURL` / `PublicUser.AvatarURL` / `SelfUser.AvatarURL` и `avatarUrl` в обоих
клиентах уже объявлены, но никто их не пишет и не отдаёт.
- Объектное хранилище фото-центрично: журнал `objects` (pending/ready/deleting) плюс
`photo_objects(photo_id, object_id, role)`; URL строится через `storage.URL(key)` как
подписанная ссылка `/api/objects/<key>?e=&s`.
- Сборка мусора (`ClaimGarbage`, Postgres и SQLite) удаляет `ready`-объекты **без привязки** в
`photo_objects`. Значит объект аватара обязан иметь привязку, иначе его подоберёт GC.
- `cmd/reconcile` сканирует и удаляет сирот только под префиксом `objects/`; ключи аватара под
этим префиксом попадут в журнал и будут безопасны.
- Обработка изображений: `disintegration/imaging` (`imaging.Fill` — квадратный кроп) и WebP.
- Android-кэш Coil (`ObjectCacheKey`) ключует по пути, **отбрасывая query**, чтобы переживать
ротацию подписи у фото. Это влияет на форму URL аватара.
## Goals / Non-Goals
**Goals:**
- Круглый аватар с загрузкой и кадрированием в вебе и Android; отображение везде, где виден пользователь.
- Хранение аватара, безопасное относительно существующего GC и `reconcile`.
- Минимум изменений в коде чтения пользователей.
**Non-Goals:**
- Серверное кадрирование по геометрии (кадр делает клиент).
- Аватары на публичных share-страницах (там сейчас нет авторов в UI).
- Хранение исходника загруженного фото как оригинального объекта аватара.
- Анимированные аватары (GIF/видео).
## Decisions
### Кадрирование на клиенте, сервер нормализует
Клиент (веб — canvas, Android — Compose) отдаёт уже готовый квадрат; сервер не принимает
геометрию и не доверяет ей. Причины: у каждой платформы есть естественный кроп-UI; серверное
API остаётся «тупым» (один файл), не нужно валидировать прямоугольник/поворот.
*Альтернатива:* серверный кроп по `x,y,w,h,angle` — один алгоритм, но API сложнее, а кроп-UI всё
равно нужен на клиенте. Отклонено.
*Реализация UI:* без новых зависимостей — собственный canvas в вебе и Compose-кроп в Android.
*Альтернатива:* сторонние библиотеки кропа; допустимо, но добавляет зависимости.
### Новый эндпоинт с одним файлом
`POST /api/auth/me/avatar` (multipart `file` = квадрат) и `DELETE /api/auth/me/avatar`. Ответ —
обновлённый self-профиль (как у прочих изменений профиля).
### Нормализация: две квадратные нарезки, WebP, без EXIF
Сервер декодирует, отклоняет не-изображения, делает `imaging.Fill` в 512 и 128 и перекодирует в
WebP. Повторный энкод снимает EXIF/GPS. Роли нарезок: `avatar` (512) и `avatar_small` (128).
*Альтернатива:* одна нарезка 512 — меньше объектов, но лишний трафик в списках. Отклонено.
*Альтернатива:* хранить оригинал аватара как object — не нужно, аватар не «открывают».
### Привязка через `user_avatar_objects` по образцу `photo_objects`
Новая таблица `user_avatar_objects(user_id, object_id, role)`. GC получает второе исключение: не
удалять объекты, на которые ссылается ни фото, ни аватар. Это сохраняет инвариант «журнал
объектов — источник правды», делает `reconcile` безопасным и **автоматически** освобождает
старый блоб при замене/удалении аватара: старая привязка снимается, объект теряет ссылку и его
подбирает существующий воркер.
*Альтернатива:* отдельный префикс (`avatars/`) вне журнала — проще, но выпадает из журнала,
лишается управления жизненным циклом и нарушает принцип из `media-objects`. Отклонено.
*Альтернатива:* хранить аватар как фото без альбома (`album_id` nullable уже есть) — переиспользует
`photo_objects` бесплатно, но засоряет фото-представления (корзина, теги). Отклонено.
### Стабильный URL с версией в пути
`GET /api/avatars/:userID/:version` отдаёт малую нарезку, `?size=large` — крупную; `:version` —
идентификатор текущего объекта аватара, сегмент нужен только для инвалидации кэша.
`users.avatar_url` заполняется значением `/api/avatars/<userID>/<version>`.
Чтение **публичное** (без авторизации): теги `<img>` и клиентские загрузчики изображений
(Coil) не могут отправить заголовок `Authorization`, поэтому защищённый маршрут отдавал бы
401 и «битую» картинку. Загрузка и удаление остаются под авторизацией. Путь не угадывается
по одному идентификатору: `userID` и `version` — непредсказуемые UUID.
Причины: существующие запросы (`members`, `social`, `photos`, `auth/me`) уже выбирают
`avatar_url`, поэтому «аватар везде» не требует правок чтения; версия **в пути**, а не в query,
потому что Coil ключует кэш по пути (query отбрасывается) — иначе после замены отдавалась бы
старая картинка.
*Альтернатива:* подписанные `/api/objects?e=&s` как у фото — работает в `<img>`, но подпись
пришлось бы резолвить в четырёх сервисах на каждый ответ, а ссылка бы истекала (нельзя хранить
в колонке). Отклонено.
*Альтернатива:* версия в query `?v=` — ломает кэш Coil без правки `ObjectCacheKey`. Отклонено.
### Заглушка и удаление
При отсутствии аватара — нейтральный силуэт: в веб-спрайт добавляется иконка `person`, в Android
используется `Icons.Filled.Person`. `DELETE /api/auth/me/avatar` снимает привязку и очищает
`avatar_url`.
### Лимит размера
Отдельный небольшой предел на файл аватара (константа 10 MB), а не общий `IMAGE_MAX_UPLOAD_SIZE`
(100 MB): аватар такого размера не нужен, а ранний отказ экономит ресурсы.
## Risks / Trade-offs
- [GC-запрос меняется в двух сторах, легко забыть SQLite] → задача явно требует правку обоих
бэкендов и тест на освобождение объекта аватара.
- [Денормализация `avatar_url` может разойтись с фактической привязкой] → URL строится из
идентификатора объекта в момент привязки; замена всегда переписывает оба.
- [Клиент может прислать не-квадрат] → сервер делает `imaging.Fill` — результат всегда квадрат
независимо от входа.
- [Coil: смена версии в пути меняет ключ кэша → старая запись остаётся на диске] → приемлемо,
Coil вытеснит её по LRU; корректность важнее.
- [Кроп-UI на двух платформах — дублирование усилий] → умышленно: каждый UI нативен платформе.
- [Учётные записи без `avatar_url`] → заглушка во всех точках отображения; значение `null`/пусто.
## Migration Plan
1. Миграция Postgres `000014_user_avatar_objects.up.sql` и SQLite `000006_user_avatar_objects.up.sql`:
создать `user_avatar_objects` с FK на `users` и `objects` (каскад/`SET NULL` по обстоятельствам),
уникальность `(user_id, role)`.
2. Правка GC (`ClaimGarbage`) в Postgres и SQLite + сборка мусора теперь учитывает аватар.
3. Бэкенд: сервис/хендлеры аватара, отдача `/api/avatars/...`, заполнение `avatar_url`, маршруты.
4. Клиенты: веб и Android.
5. Откат: удалить маршруты и таблицу; `avatar_url` очищается, объекты аватара становятся
недоступными и освобождаются GC. Существующие данные не портятся.
## Open Questions
- Точное значение предела размера (сейчас 10 MB) и набор принимаемых форматов (по факту — то, что
отдаёт canvas/Android: JPEG/PNG/WebP) можно уточнить при реализации, не меняя подход.
@@ -0,0 +1,58 @@
# Proposal
## Why
Аватар профиля задуман, но не реализован: колонка `users.avatar_url`, поля
`PublicUser.AvatarURL`/`SelfUser.AvatarURL` и `avatarUrl` в клиентах существуют, однако
никто их не пишет, изображение никто не отдаёт, а интерфейс везде показывает только имена.
Пользователи не могут выделить себя визуально. Нужно замкнуть эту заготовку в рабочую
круглую фотографию профиля с загрузкой и кадрированием на обеих платформах.
## What Changes
- Загрузка и удаление аватара: `POST /api/auth/me/avatar` (клиент присылает уже готовый
квадрат после кадрирования) и `DELETE /api/auth/me/avatar` (сброс к дефолту).
- Серверная нормализация загруженного изображения: отклонение не-изображений, снятие
EXIF/GPS повторным энкодом, две квадратные WebP-нарезки — `avatar_small` (128) и
`avatar` (512).
- Аватар хранится как объект журнала, привязанный к пользователю; сборка мусора учитывает
эту привязку, поэтому замена и сброс аватара сами освобождают старый блоб.
- Стабильная отдача под авторизацией: `GET /api/avatars/:userID/:version` (версия в пути,
чтобы клиентские и браузерные кэши инвалидировались при замене), `?size=large` отдаёт
крупную нарезку.
- `users.avatar_url` заполняется стабильной ссылкой, поэтому существующие публичные
представления пользователя (участники альбома, комментарии, авторы) начинают отдавать
аватар без изменения своих запросов.
- Веб-интерфейс: круглый аватар и загрузка с кадрированием на странице профиля; круглый
аватар в шапке, у участников, в комментариях и у автора фото.
- Android-клиент: круглый аватар и загрузка с кадрированием на экране профиля; круглый
аватар в шапке списка альбомов.
- Дефолт при отсутствии аватара — нейтральная иконка-силуэт; в веб-спрайт добавляется иконка
`person`.
## Capabilities
### New Capabilities
<!-- None: аватар относится к существующей способности профиля пользователя. -->
### Modified Capabilities
- `user-profile`: добавляются требования к аватару — загрузка с кадрированием на клиенте,
круглое отображение в вебе (шапка, страница профиля, участники, комментарии, автор) и в
Android (экран профиля, шапка списка альбомов), дефолтная заглушка и удаление аватара.
- `media-objects`: пользовательский аватар становится объектом журнала, привязанным к
пользователю, и учитывается сборкой мусора наряду с привязками к фото.
## Impact
- Backend: `internal/auth` (хендлеры/сервис/сторы), новая привязка объектов аватара и её учёт
в сборке мусора (`internal/photos/objects.go`, `sqlite_objects.go` или выделенный слой),
маршрутизация и отдача (`internal/app`), миграции Postgres и SQLite, журнал объектов.
- Frontend: `lib/api.ts`, `lib/stores/auth.svelte.ts`, `lib/icons/registry.ts`, страница
`routes/(app)/profile`, `AppShell.svelte`, `Comments.svelte`, `PhotoPanel.svelte`, экран
участников.
- Android: `ProfileScreen`/`ProfileViewModel`/`ProfileApi`/`GaloApi`, `AlbumsScreen`,
экран(ы) кадрирования; `UserDto.avatarUrl` уже есть.
- API: новые маршруты `/auth/me/avatar` и `/avatars/:userID/:version`.
- Спеки: `user-profile`, `media-objects`.
@@ -0,0 +1,42 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Сборка мусора по ссылкам
Объект хранилища SHALL удаляться только когда на него не ссылается ни одно фото и ни один
аватар пользователя; удаление альбома и попадание фото в корзину SHALL сохранять объекты.
#### Scenario: Последняя ссылка удалена
- **WHEN** фото удаляется навсегда и больше ни одно фото не ссылается на его объекты
- **THEN** объекты удаляются из хранилища
#### Scenario: Остались ссылки
- **WHEN** на объект ссылается другое фото
- **THEN** объект сохраняется в хранилище
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** объекты хранилища не удаляются
#### Scenario: Ссылка аватара
- **WHEN** на объект ссылается аватар пользователя
- **THEN** объект сохраняется в хранилище, пока ссылка существует
## ADDED Requirements
### Requirement: Аватар пользователя как объект журнала
Аватар пользователя SHALL храниться как объект журнала, привязанный к пользователю по ролям,
соответствующим нарезкам аватара. Замена и удаление аватара SHALL менять привязку, а объекты
без ссылок SHALL освобождаться сборкой мусора.
#### Scenario: Привязка аватара
- **WHEN** пользователь устанавливает аватар
- **THEN** объекты нарезок привязываются к пользователю по своим ролям
#### Scenario: Замена аватара
- **WHEN** пользователь заменяет аватар
- **THEN** привязка указывает на новые объекты, а старые освобождаются сборкой мусора
#### Scenario: Удаление аватара
- **WHEN** пользователь удаляет аватар
- **THEN** привязка снимается, и объекты аватара освобождаются сборкой мусора
@@ -0,0 +1,89 @@
# Spec Delta
## ADDED Requirements
### Requirement: Загрузка фото профиля с выбором области
Пользователь SHALL загружать собственное фото в профиль, выбирая видимую квадратную область.
Сохранённый аватар SHALL соответствовать выбранной области. Загрузка SHALL быть доступна только
владельцу профиля.
#### Scenario: Успешная загрузка
- **WHEN** пользователь выбирает изображение и подтверждает квадратную область
- **THEN** аватар обновляется и возвращается в профиле
#### Scenario: Выбранная область учтена
- **WHEN** пользователь сдвигает или масштабирует область перед подтверждением
- **THEN** сохранённый аватар показывает именно выбранную область
#### Scenario: Не изображение
- **WHEN** загруженный файл не является декодируемым изображением
- **THEN** система отклоняет запрос и не меняет аватар
### Requirement: Нормализация аватара и снятие метаданных
Система SHALL сохранять аватар в виде квадратных изображений фиксированных размеров и SHALL NOT
сохранять EXIF-метаданные загруженного файла.
#### Scenario: Метаданные не сохраняются
- **WHEN** загружено изображение с EXIF, включая GPS-координаты
- **THEN** сохранённый аватар не содержит EXIF и GPS
#### Scenario: Слишком большой файл
- **WHEN** размер загруженного файла превышает допустимый предел
- **THEN** система отклоняет запрос и не меняет аватар
### Requirement: Отдача аватара по стабильному URL
Профиль и публичные представления пользователя SHALL содержать URL аватара, когда он задан.
URL SHALL быть стабильным и SHALL изменяться при обновлении аватара, чтобы клиентские кэши
инвалидировались. Изображение SHALL отдаваться по публичной ссылке без аутентификации, чтобы
его могли загружать теги изображений и клиентские загрузчики.
#### Scenario: Ссылка в представлениях пользователя
- **WHEN** аутентифицированный клиент запрашивает профиль, участников альбома, комментарии или автора фото
- **THEN** представления содержат URL аватара, если он задан
#### Scenario: Кэш инвалидируется при замене
- **WHEN** пользователь заменяет аватар
- **THEN** URL аватара изменяется
#### Scenario: Загрузка изображения без заголовка авторизации
- **WHEN** браузер или клиентский загрузчик изображений запрашивает изображение аватара по URL без заголовка авторизации
- **THEN** система отдаёт изображение
### Requirement: Круглое отображение аватара в веб-интерфейсе
Веб-приложение SHALL отображать аватар пользователя круглым в шапке, на странице профиля, у
участников альбома, в комментариях и у автора фото.
#### Scenario: Аватар в шапке
- **WHEN** аутентифицированный пользователь открывает любое место приложения
- **THEN** в шапке отображается его круглый аватар
#### Scenario: Аватар рядом с другими пользователями
- **WHEN** пользователь просматривает участников, комментарии или автора фото
- **THEN** аватары этих пользователей отображаются круглыми
### Requirement: Круглое отображение аватара в Android-клиенте
Android-приложение SHALL отображать аватар пользователя круглым на экране профиля и в шапке
списка альбомов.
#### Scenario: Аватар на экране профиля
- **WHEN** пользователь открывает экран профиля
- **THEN** отображается его круглый аватар
#### Scenario: Аватар в шапке списка альбомов
- **WHEN** пользователь открывает список альбомов
- **THEN** в шапке отображается его круглый аватар
### Requirement: Заглушка при отсутствии аватара
Когда аватар не задан, система SHALL показывать нейтральную заглушку-силуэт вместо изображения.
#### Scenario: Аватар не задан
- **WHEN** у пользователя нет аватара и его представление отображается в вебе или Android
- **THEN** вместо изображения показывается нейтральная круглая заглушка-силуэт
### Requirement: Удаление аватара
Пользователь SHALL удалять свой аватар; после удаления SHALL показываться заглушка, а связанные
объекты хранилища SHALL освобождаться.
#### Scenario: Сброс аватара
- **WHEN** пользователь удаляет аватар
- **THEN** представления возвращаются к заглушке, а объекты старого аватара освобождаются
+47
View File
@@ -0,0 +1,47 @@
# Tasks
## 1. Модель данных и сборка мусора
- [x] 1.1 Добавить миграцию Postgres `000014_user_avatar_objects.up.sql`/`.down.sql` и SQLite `000006_user_avatar_objects.up.sql`/`.down.sql`: таблица `user_avatar_objects(user_id, object_id, role)` с FK на `users` и `objects` и уникальностью `(user_id, role)`; проверить, что тесты миграций (`go test ./internal/db/...`) проходят и `make migrate` применяет и откатывает.
- [x] 1.2 Расширить `ClaimGarbage` в Postgres-сторе (`internal/photos/objects.go`): не считать мусором объекты, на которые ссылается `user_avatar_objects`; проверить тестом, что привязанный объект аватара переживает сборку, а после снятия привязки освобождается.
- [x] 1.3 Расширить `ClaimGarbage` в SQLite-сторе (`internal/photos/sqlite_objects.go`) тем же исключением; проверить аналогичным тестом на SQLite.
- [x] 1.4 Добавить методы стора для аватара (привязать/заменить/снять по ролям `avatar`/`avatar_small`, получить текущие объекты) на обоих бэкендах; проверить контрактным тестом, покрывающим оба бэкенда.
## 2. Backend: нормализация и жизненный цикл объекта аватара
- [x] 2.1 Реализовать нормализацию аватара: декодировать, отклонить не-изображение, `imaging.Fill` в 512 и 128, перекодировать в WebP (снятие EXIF/GPS); проверить юнит-тестом на JPEG с EXIF/GPS — результат квадратный 512 и 128 без EXIF.
- [x] 2.2 Реализовать сервис установки/замены/удаления аватара через журнал объектов (pending → Put → ready → привязка, откат при ошибке); проверить тестом, что сбой не оставляет объектов, а успех привязывает обе роли.
- [x] 2.3 Применить предел размера файла (10 MB) и отклонение не-изображений на границе сервиса; проверить тестами на превышение размера и на не-изображение.
## 3. Backend: API и отдача
- [x] 3.1 Добавить хендлеры `POST /api/auth/me/avatar` (multipart `file`) и `DELETE /api/auth/me/avatar`, возвращающие обновлённый self-профиль; проверить тестами: без аутентификации — отказ, после загрузки `avatar_url` задан, после удаления — пуст.
- [x] 3.2 Добавить отдачу `GET /api/avatars/:userID/:version` (малая нарезка, `?size=large` — крупная) под авторизацией; проверить тестами: без аутентификации — отказ, версия меняется при замене, отсутствующий пользователь/аватар — корректная ошибка.
- [x] 3.3 Зарегистрировать маршруты в `internal/app/app.go`; проверить тестом маршрутизации, что новые пути доступны и защищены middleware.
## 4. Аватар в представлениях пользователей
- [x] 4.1 Убедиться, что `auth/me`, участники, комментарии и автор фото отдают заполненный `avatar_url` (запросы уже выбирают колонку) и что `PublicUser`/`SelfUser` сериализуют поле; проверить тестами: у пользователя с аватаром `avatar_url` присутствует, без аватара — пуст.
## 5. Веб-интерфейс
- [x] 5.1 Добавить в `lib/api.ts` методы загрузки и удаления аватара и сохранять `avatar_url` в auth-стор; проверить `npm run check` (типы) без ошибок.
- [x] 5.2 Добавить иконку `person` в `lib/icons/registry.ts` и компонент круглого аватара с заглушкой-силуэтом; проверить, что компонент показывает изображение при заданном URL и силуэт при пустом.
- [x] 5.3 На странице `routes/(app)/profile` добавить выбор файла, канвас-кроппер и кнопки загрузки/удаления; проверить вручную: загрузка меняет аватар, удаление возвращает заглушку.
- [x] 5.4 Показывать круглый аватар в `AppShell` (шапка), у участников, в `Comments.svelte` и у автора в `PhotoPanel.svelte`; проверить визуально, что заглушка и реальный аватар отображаются круглыми.
## 6. Android-клиент
- [x] 6.1 Добавить в `ProfileApi`/`GaloApi` загрузку (multipart) и удаление аватара, а `ProfileViewModel` — `avatarUrl` и действия; проверить юнит-тестами ViewModel (успех, ошибка, обновление состояния).
- [x] 6.2 На `ProfileScreen` показать круглый аватар, выбор изображения и Compose-кроппер, кнопку удаления; проверить Compose-тестом и вручную, что аватар обновляется и сбрасывается.
- [x] 6.3 Показать круглый аватар в шапке `AlbumsScreen`; проверить Compose-тестом, что при заданном `avatarUrl` рисуется изображение, иначе — силуэт.
## 7. Интеграция
- [x] 7.1 Прогнать сквозной сценарий: загрузка аватара отражается в шапке, у участников и в комментариях; замена освобождает старый объект (проверить сборкой мусора); удаление возвращает заглушку — тест проходит на обоих бэкендах метаданных.
- [x] 7.2 Обновить документацию проекта (README/project.md), где перечислены эндпоинты и схема; проверить, что новые маршруты и таблица упомянуты.
## Workflow follow-up
- Архивировать изменение после прохождения ревью.
- Проверить результат в архивированных спеках `user-profile` и `media-objects`.
+15 -3
View File
@@ -341,6 +341,7 @@ colors: {
taken_at, latitude, longitude, caption, exif, deleted_at, created_at)
- **objects** (id, storage_key, mime_type, size, width, height, checksum, state, created_at)
- **photo_objects** (photo_id, object_id, role)
- **user_avatar_objects** (user_id, object_id, role)
- **variant_jobs** (photo_id, attempts, next_attempt_at, last_error)
- **comments** (id, photo_id, user_id, text, created_at, updated_at)
- **likes** (photo_id, user_id, created_at)
@@ -372,15 +373,17 @@ colors: {
**Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime,
размер, габариты, checksum, состояние `pending|ready|deleting`), таблица
`photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`),
таблица `variant_jobs` — очередь генерации вариантов. Альбом, принадлежность и
представление живут только в `photos`/`albums`.
таблица `user_avatar_objects` — аватар пользователя с его нарезками
(`avatar|avatar_small`), таблица `variant_jobs` — очередь генерации вариантов.
Альбом, принадлежность и представление живут только в `photos`/`albums`.
Следствия:
- **Копирование** между альбомами — только новые строки `photos`+`photo_objects`,
ссылающиеся на те же объекты; операции с хранилищем не выполняются.
- **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются.
- **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на
объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям).
объект не осталось ни одной ссылки (фото или аватар) — сборка мусора по
ledger-состояниям.
Доступ: **только через бэкенд** — подписанный URL со сроком 1 час на
`/api/objects/<key>`, независимо от бэкенда хранилища. Для S3 (Garage) байты
@@ -402,6 +405,15 @@ colors: {
- `POST /api/auth/refresh`
- `POST /api/auth/logout`
### Profile
- `GET|PATCH|DELETE /api/auth/me`
- `POST /api/auth/me/password`
- `POST /api/auth/me/identifiers`
- `PATCH /api/auth/me/preferences`
- `POST /api/auth/me/avatar` — загрузка готового квадрата (multipart `file`)
- `DELETE /api/auth/me/avatar` — сброс к заглушке
- `GET /api/avatars/:userID/:version?size=large` — публичная отдача аватара (без авторизации)
### Albums
- `GET /api/albums`
- `POST /api/albums`