/** * 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' }) } })