Implementar Web Share Target API para compartir fotos con la PWA
Some checks failed
build-and-deploy / build-and-deploy (push) Has been cancelled

- Agregar share_target al manifest de la PWA
- Crear endpoint /api/share-target para recibir archivos compartidos
- Guardar archivos temporalmente en /public/temp-shared
- Modificar UserProfileForm para aceptar imágenes externas
- Detectar automáticamente imágenes compartidas y procesarlas
- Crear endpoint /api/share-target/cleanup para limpiar temporales
- Mostrar toast informativo al recibir imagen compartida
- Redirigir automáticamente al formulario de perfil
- Soportar compartir desde galería, otras apps, etc.
This commit is contained in:
2025-10-17 18:29:00 -06:00
parent ced637a7a9
commit 5bb5e5092e
17 changed files with 367 additions and 1 deletions

View File

@@ -19,7 +19,8 @@
<!-- Formulario de edición de perfil o Lista de aplicaciones -->
<UserProfileForm
v-if="showProfileForm"
@close="showProfileForm = false"
:shared-image-url="sharedImageUrl"
@close="handleCloseProfileForm"
/>
<AuthApplicationsList v-else />
@@ -51,10 +52,38 @@
<script setup lang="ts">
const { isAuthenticated } = useAuthentik()
const { isNight } = useTheme()
const route = useRoute()
const router = useRouter()
// Estado para mostrar formulario de edición
const showProfileForm = ref(false)
// URL de imagen compartida (si existe)
const sharedImageUrl = ref<string | null>(null)
// Detectar si se compartió una imagen
onMounted(() => {
const sharedToken = route.query.shared as string
const sharedFile = route.query.file as string
if (sharedToken && sharedFile) {
// Construir URL del archivo temporal
sharedImageUrl.value = `/temp-shared/${sharedFile}`
// Abrir automáticamente el formulario de perfil
showProfileForm.value = true
// Limpiar query params de la URL sin recargar
router.replace({ query: {} })
}
})
// Manejar cierre del formulario
const handleCloseProfileForm = () => {
showProfileForm.value = false
sharedImageUrl.value = null
}
// Configurar meta tags para PWA
useHead({
htmlAttrs: {

View File

@@ -420,6 +420,11 @@
const { user } = useAuthentik()
const toast = useToast()
// Props
const props = defineProps<{
sharedImageUrl?: string | null
}>()
// Emits
const emit = defineEmits(['close'])
@@ -544,6 +549,13 @@ if (import.meta.client) {
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Detectar y procesar imagen compartida automáticamente
watch(() => props.sharedImageUrl, async (newUrl) => {
if (newUrl && import.meta.client) {
await processSharedImage(newUrl)
}
}, { immediate: true })
}
// Enviar formulario
@@ -802,6 +814,55 @@ const processImageFile = async (file: File) => {
const blob = new Blob([file], { type: file.type })
await handleAvatarCapture(blob)
}
// Procesar imagen compartida desde Web Share Target
const processSharedImage = async (imageUrl: string) => {
try {
// Mostrar notificación
toast.add({
title: 'Imagen compartida recibida',
description: 'Procesando imagen para tu avatar...',
color: 'info',
icon: 'i-heroicons-photo'
})
// Descargar la imagen del servidor
const response = await fetch(imageUrl)
if (!response.ok) {
throw new Error('No se pudo cargar la imagen compartida')
}
const blob = await response.blob()
// Validar que sea una imagen
if (!blob.type.startsWith('image/')) {
throw new Error('El archivo compartido no es una imagen válida')
}
// Procesar y subir la imagen
await handleAvatarCapture(blob)
// Limpiar archivo temporal del servidor
try {
await $fetch('/api/share-target/cleanup', {
method: 'POST',
body: { imageUrl }
})
} catch (cleanupError) {
console.error('Error limpiando archivo temporal:', cleanupError)
// No es crítico si falla la limpieza
}
} catch (error: any) {
console.error('Error procesando imagen compartida:', error)
toast.add({
title: 'Error',
description: error.message || 'No se pudo procesar la imagen compartida',
color: 'error',
icon: 'i-heroicons-exclamation-triangle'
})
}
}
</script>
<style scoped>

View File

@@ -50,6 +50,22 @@ export default defineNuxtConfig({
// NOTA: capture_links es experimental pero REQUERIDO para el funcionamiento correcto
// TypeScript dará error TS2353 pero se ignora intencionalmente (ver README.md)
capture_links: 'existing-client-navigate',
// Share Target API - Permite compartir fotos con la PWA
share_target: {
action: '/api/share-target',
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
files: [
{
name: 'media',
accept: ['image/*']
}
]
}
},
// Extender scope a otros subdominios de Nucleo V3
scope_extensions: [
{

View File

@@ -0,0 +1,75 @@
/**
* API endpoint para Web Share Target
* POST /api/share-target
*
* Recibe imágenes compartidas desde otras apps y las almacena temporalmente
*/
import { promises as fs } from 'fs'
import { join } from 'path'
import { createHash, randomBytes } from 'crypto'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']
const TEMP_DIR = 'public/temp-shared'
export default defineEventHandler(async (event) => {
try {
// Leer el body como multipart/form-data
const formData = await readMultipartFormData(event)
if (!formData || formData.length === 0) {
// No hay archivo, redirigir a inicio
return sendRedirect(event, '/', 302)
}
// Buscar el campo 'media' (nombre definido en manifest)
const mediaFile = formData.find(field => field.name === 'media')
if (!mediaFile) {
// No hay imagen, redirigir a inicio
return sendRedirect(event, '/', 302)
}
// Validar tipo de archivo
const contentType = mediaFile.type || ''
if (!ALLOWED_TYPES.includes(contentType)) {
console.error('Tipo de archivo no permitido:', contentType)
return sendRedirect(event, '/', 302)
}
// Validar tamaño
if (mediaFile.data.length > MAX_FILE_SIZE) {
console.error('Archivo muy grande:', mediaFile.data.length)
return sendRedirect(event, '/', 302)
}
// Crear directorio temporal si no existe
const tempPath = join(process.cwd(), TEMP_DIR)
try {
await fs.access(tempPath)
} catch {
await fs.mkdir(tempPath, { recursive: true })
}
// Generar token único para este archivo temporal
const token = randomBytes(16).toString('hex')
const timestamp = Date.now()
const extension = contentType.split('/')[1]
const filename = `shared-${token}-${timestamp}.${extension}`
const filepath = join(tempPath, filename)
// Guardar archivo temporal
await fs.writeFile(filepath, mediaFile.data)
// URL pública del archivo temporal
const tempUrl = `/temp-shared/${filename}`
console.log(`✅ Imagen compartida guardada temporalmente: ${tempUrl}`)
// Redirigir a la página principal con el token
return sendRedirect(event, `/?shared=${token}&file=${encodeURIComponent(filename)}`, 302)
} catch (error: any) {
console.error('Error en share-target:', error)
return sendRedirect(event, '/', 302)
}
})

View File

@@ -0,0 +1,68 @@
/**
* API endpoint para limpiar archivos temporales de Web Share Target
* POST /api/share-target/cleanup
*
* Elimina archivos temporales después de ser procesados
*/
import { promises as fs } from 'fs'
import { join } from 'path'
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { imageUrl } = body
if (!imageUrl || typeof imageUrl !== 'string') {
throw createError({
statusCode: 400,
message: 'imageUrl es requerido'
})
}
// Extraer el filename de la URL (/temp-shared/filename.jpg)
const filename = imageUrl.split('/').pop()
if (!filename || !filename.startsWith('shared-')) {
throw createError({
statusCode: 400,
message: 'URL de imagen inválida'
})
}
// Construir ruta completa del archivo
const filepath = join(process.cwd(), 'public', 'temp-shared', filename)
// Intentar eliminar el archivo
try {
await fs.unlink(filepath)
console.log(`🗑️ Archivo temporal eliminado: ${filename}`)
return {
success: true,
message: 'Archivo temporal eliminado correctamente'
}
} catch (err: any) {
// Si el archivo no existe, no es un error crítico
if (err.code === 'ENOENT') {
console.log(`⚠️ Archivo temporal ya no existe: ${filename}`)
return {
success: true,
message: 'Archivo ya había sido eliminado'
}
}
throw err
}
} catch (error: any) {
console.error('Error en cleanup de share-target:', error)
// Re-throw si ya es un error de createError
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: error.message || 'Error al eliminar archivo temporal'
})
}
})