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:
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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** представления возвращаются к заглушке, а объекты старого аватара освобождаются
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user