Implementar sistema completo de captura y gestión de avatares
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 57s
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
This commit is contained in:
164
nuxt4/server/api/avatar/upload.post.ts
Normal file
164
nuxt4/server/api/avatar/upload.post.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user