Implementar Web Share Target API para compartir fotos con la PWA
Some checks failed
build-and-deploy / build-and-deploy (push) Has been cancelled
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:
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
75
nuxt4/server/api/share-target.post.ts
Normal file
75
nuxt4/server/api/share-target.post.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
68
nuxt4/server/api/share-target/cleanup.post.ts
Normal file
68
nuxt4/server/api/share-target/cleanup.post.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user