All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
- Agregar CameraCapture.vue con soporte multi-dispositivo * Soporte para múltiples cámaras (frontal/trasera) * Manejo robusto de permisos y errores * Preview y confirmación de foto * Detección automática de capacidades del dispositivo - Crear endpoint /api/avatar/upload para subir avatares * Validación de tipo y tamaño de archivo * Almacenamiento en /public/avatars/ * Actualización de atributos en Authentik * Limpieza automática de avatares antiguos - Actualizar UserProfileForm con gestión de avatar * Integración con CameraCapture en modal * Preview del avatar actual con MsnAvatar * Opciones para cambiar y eliminar avatar - Actualizar useAuthentik para avatares personalizados * Carga de atributos completos del usuario * Soporte para avatar_url desde Authentik * Fallback a UI Avatars si no hay custom avatar
165 lines
4.8 KiB
TypeScript
165 lines
4.8 KiB
TypeScript
/**
|
|
* API endpoint para subir avatar del usuario
|
|
* POST /api/avatar/upload
|
|
*
|
|
* Recibe una imagen, la guarda en /public/avatars/ y actualiza Authentik
|
|
*/
|
|
import { promises as fs } from 'fs'
|
|
import { join } from 'path'
|
|
import { createHash } from 'crypto'
|
|
|
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
|
const AVATARS_DIR = 'public/avatars'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const config = useRuntimeConfig()
|
|
const headers = getRequestHeaders(event)
|
|
|
|
// Verificar autenticación
|
|
const username = headers['x-authentik-username']
|
|
if (!username) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
message: 'Usuario no autenticado'
|
|
})
|
|
}
|
|
|
|
try {
|
|
// Leer el body como multipart/form-data
|
|
const formData = await readMultipartFormData(event)
|
|
|
|
if (!formData || formData.length === 0) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: 'No se recibió ningún archivo'
|
|
})
|
|
}
|
|
|
|
// Buscar el campo 'avatar'
|
|
const avatarFile = formData.find(field => field.name === 'avatar')
|
|
|
|
if (!avatarFile) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: 'Campo "avatar" no encontrado'
|
|
})
|
|
}
|
|
|
|
// Validar tipo de archivo
|
|
const contentType = avatarFile.type || ''
|
|
if (!ALLOWED_TYPES.includes(contentType)) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: `Tipo de archivo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}`
|
|
})
|
|
}
|
|
|
|
// Validar tamaño
|
|
if (avatarFile.data.length > MAX_FILE_SIZE) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: `Archivo muy grande. Máximo: ${MAX_FILE_SIZE / (1024 * 1024)}MB`
|
|
})
|
|
}
|
|
|
|
// Crear directorio de avatares si no existe
|
|
const avatarsPath = join(process.cwd(), AVATARS_DIR)
|
|
try {
|
|
await fs.access(avatarsPath)
|
|
} catch {
|
|
await fs.mkdir(avatarsPath, { recursive: true })
|
|
}
|
|
|
|
// Generar nombre de archivo único basado en username
|
|
const hash = createHash('md5').update(username).digest('hex').substring(0, 8)
|
|
const timestamp = Date.now()
|
|
const extension = contentType.split('/')[1]
|
|
const filename = `${username}-${hash}-${timestamp}.${extension}`
|
|
const filepath = join(avatarsPath, filename)
|
|
|
|
// Guardar archivo
|
|
await fs.writeFile(filepath, avatarFile.data)
|
|
|
|
// URL pública del avatar
|
|
const avatarUrl = `/avatars/${filename}`
|
|
|
|
// Actualizar Authentik con la URL del avatar
|
|
const authentikUrl = config.authentikApiUrl || config.public.authentikUrl
|
|
const authentikToken = config.authentikApiToken
|
|
|
|
if (!authentikToken) {
|
|
console.warn('⚠️ Token de Authentik no configurado, no se puede actualizar metadatos')
|
|
} else {
|
|
try {
|
|
// Obtener ID del usuario
|
|
const usersResponse = await $fetch(`${authentikUrl}/api/v3/core/users/?username=${username}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${authentikToken}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
const users = usersResponse as any
|
|
if (users.results && users.results.length > 0) {
|
|
const userId = users.results[0].pk
|
|
|
|
// Actualizar atributos del usuario
|
|
await $fetch(`${authentikUrl}/api/v3/core/users/${userId}/`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${authentikToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: {
|
|
attributes: {
|
|
...users.results[0].attributes,
|
|
avatar_url: avatarUrl
|
|
}
|
|
}
|
|
})
|
|
|
|
console.log(`✅ Avatar actualizado para usuario ${username}: ${avatarUrl}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Error actualizando Authentik:', err)
|
|
// No fallar si Authentik no se puede actualizar, el avatar ya está guardado
|
|
}
|
|
}
|
|
|
|
// Limpiar avatares antiguos del usuario (opcional)
|
|
try {
|
|
const files = await fs.readdir(avatarsPath)
|
|
const userFiles = files.filter(f =>
|
|
f.startsWith(`${username}-`) &&
|
|
f !== filename
|
|
)
|
|
|
|
for (const oldFile of userFiles) {
|
|
await fs.unlink(join(avatarsPath, oldFile))
|
|
console.log(`🗑️ Eliminado avatar antiguo: ${oldFile}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Error limpiando avatares antiguos:', err)
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
avatarUrl,
|
|
message: 'Avatar actualizado correctamente'
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error en upload de avatar:', error)
|
|
|
|
// Re-throw si ya es un error de createError
|
|
if (error.statusCode) {
|
|
throw error
|
|
}
|
|
|
|
throw createError({
|
|
statusCode: 500,
|
|
message: error.message || 'Error al subir avatar'
|
|
})
|
|
}
|
|
})
|