feat: implementar webapp app_compartir con visor universal, auth y purga 24h para guest

This commit is contained in:
2026-10-13 22:46:02 -05:00
parent 6ea00e4ec5
commit 8519590cc7
24 changed files with 2970 additions and 71 deletions
+9 -1
View File
@@ -11,7 +11,8 @@
| # | Objetivo | Origen | Prioridad |
|---|----------|--------|-----------|
| 1 | Definir funcionalidades clave de app_compartir | pablinux | Alta |
| 1 | Soporte para compresión zip en descargas multi-archivo | pablinux | Media |
| 2 | Notificación opcional por bot o webhook al compartir | pablinux | Baja |
---
@@ -28,3 +29,10 @@
| Fecha | Objetivo | Notas |
|---|---|---|
| 2026-10-12 | Estructura base de agentes y documentación inicializada | Plantilla Telcotronics |
| 2026-10-13 | Subida rápida de archivos (drag & drop, pegar con Ctrl+V, selector) | Soporte para multipart y streaming de hasta 500MB |
| 2026-10-13 | Compartir texto/código o notas rápidas | Endpoint `/api/share-text` con extracción automática de título |
| 2026-10-13 | Visor universal embebido (`/v/{id}`) para imágenes, videos, audios, PDFs, textos | Detección automática MIME y reproductores dedicados |
| 2026-10-13 | Generador instantáneo de enlaces y código QR para móviles | Generación de QR nativa con `github.com/skip2/go-qrcode` |
| 2026-10-13 | Sesión Guest con auto-eliminación obligatoria en 24h (física y BD) | Worker periódico de purga en segundo plano verificado con tests |
| 2026-10-13 | Cuentas registradas (Google y WebControl) con almacenamiento permanente | Cuentas no-guest exentas de borrado a las 24h |
| 2026-10-13 | Panel / lista de "Mis Archivos" para usuarios registrados con opción de borrado | Interfaz `/mis-archivos` con métricas de visitas y eliminación |
+55 -11
View File
@@ -9,27 +9,71 @@
```
app_compartir/
├── .agente/
│ ├── ideas.md
│ ├── infraestructura.md
│ ├── objetivos.md
│ ├── proyecto_errores.md
│ ├── proyecto_estructura.md
│ └── proyecto_memoria.md
├── internal/
│ ├── auth/
│ │ └── auth.go # Manejo de sesiones, WebControl y Google OAuth2
│ ├── cleaner/
│ │ └── cleaner.go # Worker periódico de purga (24h para guest)
│ ├── config/
│ │ └── config.go # Carga de configuración y .env
│ ├── db/
│ │ └── db.go # SQLite DAL, esquema y consultas
│ ├── handlers/
│ │ └── handlers.go # Rutas HTTP, endpoints API y renderizado HTML
│ ├── models/
│ │ └── models.go # Estructuras de datos (Share, User, Session)
│ └── storage/
│ └── storage.go # Slugs aleatorios, sniffing MIME y guardado en disco
├── web/
│ └── templates/
│ ├── base.html # Layout común con Tailwind CSS y FontAwesome
│ ├── expired.html # Mensaje informativo de archivo guest caducado (24h)
│ ├── index.html # Drag & Drop, pegar portapapeles y compartir texto
│ ├── login.html # Login con WebControl y Google OAuth
│ ├── my_shares.html # Panel de usuario con sus archivos permanentes
│ └── viewer.html # Visor universal (imágenes, video, audio, PDF, código)
├── .env.example
├── .gitignore
├── go.mod
├── go.sum
├── main.go # Punto de entrada y servidor HTTP Go
├── main_test.go # Tests unitarios/integración (purga 24h vs permanencia)
├── README.md
├── agents.md
└── .agente/
├── infraestructura.md
├── proyecto_estructura.md
├── proyecto_memoria.md
├── proyecto_errores.md
├── objetivos.md
└── ideas.md
└── agents.md # Punto de entrada del agente
```
---
## Rutas (HTTP / CLI / eventos)
## Rutas HTTP / Endpoints
| Método | Ruta | Handler | Descripción |
|---|---|---|---|
| [COMPLETAR] | [COMPLETAR] | [COMPLETAR] | [COMPLETAR] |
| `GET` | `/` | `h.Index` | Página principal de subida (drag & drop, portapapeles, texto) |
| `POST` | `/api/upload` | `h.Upload` | Subida de archivos multipart |
| `POST` | `/api/share-text` | `h.ShareText` | Subida rápida de notas, textos o código |
| `GET` | `/v/{id}` | `h.View` | Visor universal de contenidos compartidos |
| `GET` | `/d/{id}` | `h.Download` | Descarga directa forzada (`attachment`) |
| `GET` | `/raw/{id}` | `h.Raw` | Visualización directa (`inline`) |
| `GET` | `/qr/{id}` | `h.QR` | Generador dinámico de código QR en PNG |
| `DELETE`| `/api/shares/{id}` | `h.DeleteShare` | Eliminación de archivo (usuario propietario) |
| `GET` | `/login` | `h.LoginView` | Pantalla de inicio de sesión |
| `POST` | `/login/webcontrol`| `h.WebControlLogin` | Autenticación con credenciales WebControl |
| `GET` | `/auth/google` | `h.GoogleLogin` | Redirección OAuth a Google |
| `GET` | `/auth/google/callback` | `h.GoogleCallback` | Retorno de autenticación OAuth Google |
| `GET` | `/logout` | `h.Logout` | Cierre de sesión y limpieza de cookie |
| `GET` | `/mis-archivos` | `h.MyShares` | Panel con archivos permanentes del usuario |
---
## Dependencias entre módulos
[COMPLETAR]
- `main.go` orquesta `config`, `db`, `cleaner`, `auth` y `handlers`.
- `handlers` utiliza `storage` para guardar archivos y `qrcode` para generar códigos de acceso móvil.
- `cleaner` consulta periódicamente `db` y elimina archivos físicos de `data/uploads/` para shares `is_guest = true` con `expires_at <= NOW()`. Las cuentas registradas son ignoradas por el cleaner.
+12 -5
View File
@@ -10,23 +10,30 @@
| Fecha | Decisión | Razonamiento |
|---|---|---|
| 2026-10-12 | Inicialización de la estructura estándar de documentación de agentes | Estandarización bajo el ecosistema Telcotronics |
| 2026-10-12 | Política de caducidad 24h exclusiva para usuarios Guest | El usuario solicitó explícitamente que los archivos subidos por invitados se eliminen a las 24 horas, mientras que los usuarios registrados (Google OAuth / WebControl) mantengan sus archivos de manera permanente. |
| 2026-10-12 | Visor integrado universal para evitar depender de WhatsApp | Permite abrir imágenes, reproducir video/audio, leer PDFs y ver texto o código directamente desde el navegador sin apps externas. |
| 2026-10-12 | Backend autónomo en Go con SQLite | Binario único de alto rendimiento, bajo consumo de memoria y concurrencia nativa con goroutine para la limpieza periódica. |
---
## Estado actual
Proyecto recién inicializado. Estructura de documentación base creada. Pendiente definir objetivos específicos, stack detallado y requerimientos.
Estructura de documentación y requerimientos alineados. Desarrollando servidor en Go con base de datos SQLite, endpoints de subida, visor interactivo y worker de purga a 24 horas.
---
## Pendientes
- [ ] Definir alcance y objetivos del proyecto `app_compartir`
- [ ] Inicializar módulo Go (`go mod init app_compartir`)
- [ ] Definir arquitectura base
- [ ] Implementar base de datos SQLite y esquema de tablas (`shares`, `users`, `sessions`)
- [ ] Implementar endpoints HTTP de subida (`/api/upload`, `/api/share-text`)
- [ ] Implementar visor `/v/{id}` (HTML + Tailwind + QR)
- [ ] Implementar worker de purga de archivos guest vencidos
- [ ] Implementar auth con WebControl (`api.siax-system.net`) y Google OAuth2
- [ ] Implementar panel "Mis Archivos" para usuarios autenticados
---
## Notas y observaciones
[COMPLETAR]
- Los archivos se almacenarán en `data/uploads/` y la base de datos en `data/compartir.db`.
- Los enlaces cortos utilizarán slugs alfanuméricos seguros y compactos.
+12
View File
@@ -0,0 +1,12 @@
# Configuración de app_compartir
PORT=8085
BASE_URL=http://localhost:8085
DATA_DIR=./data
SESSION_SECRET=cambiar_esta_clave_secreta_en_produccion
# Autenticación Corporativa WebControl
WEBCONTROL_API_URL=https://api.siax-system.net
# Autenticación Google OAuth2 (Opcional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
+18
View File
@@ -0,0 +1,18 @@
# Binarios
app_compartir
*.exe
*.test
# Datos locales
data/
uploads/
*.db
*.db-wal
*.db-shm
# Variables de entorno
.env
# Sistema operativo
.DS_Store
Thumbs.db
+43 -7
View File
@@ -1,6 +1,22 @@
# app_compartir
Módulo / Servicio del ecosistema Telcotronics en Go.
Aplicación web de alto rendimiento en Go para compartir archivos, fotos, documentos y texto/código al instante entre dispositivos (móvil y PC), sin depender de clientes de mensajería como WhatsApp.
---
## Características
- ⚡ **Sin fricción:** Subida mediante Drag & Drop, selección de archivos, pegado desde portapapeles (`Ctrl+V`) o editor de notas/código.
- 📱 **QR instantáneo:** Generación automática de código QR para escanear y abrir de inmediato en el teléfono.
- 👁️ **Visor universal (`/v/{id}`):**
- **Imágenes:** Zoom interactivo, pantalla completa y alta fidelidad.
- **Video y Audio:** Reproductor HTML5 integrado.
- **PDFs:** Visor interactivo incrustado en el navegador.
- **Texto y Código:** Formato monospace con botón para copiar al portapapeles en 1 clic.
- **Otros archivos:** Ficha de descarga con detección de tipo MIME y peso exacto.
- ⏳ **Política de retención 24h para invitados:**
- Los archivos subidos en **Modo Invitado (Guest)** se purgan automáticamente tras 24 horas del disco y de la base de datos.
- Los usuarios autenticados con **WebControl** o **Google OAuth** disponen de **almacenamiento permanente** y panel de gestión para sus archivos.
---
@@ -8,18 +24,38 @@ Módulo / Servicio del ecosistema Telcotronics en Go.
- Go 1.22+
## Instalación
## Instalación y Arranque
```bash
# go mod download
# Copiar variables de entorno
cp .env.example .env
# Descargar dependencias y compilar
go mod tidy
go build -o app_compartir main.go
# Ejecutar el servidor (puerto 8085 por defecto)
./app_compartir
```
## Variables de entorno
---
## Variables de Entorno
| Variable | Descripción | Default |
|---|---|---|
| [COMPLETAR] | [COMPLETAR] | [COMPLETAR] |
| `PORT` | Puerto de escucha HTTP | `8085` |
| `BASE_URL` | URL pública de la aplicación | `http://localhost:8085` |
| `DATA_DIR` | Carpeta para base de datos y uploads | `./data` |
| `SESSION_SECRET` | Clave secreta para cookies de sesión | `super-secret-...` |
| `WEBCONTROL_API_URL` | URL de la API de WebControl | `https://api.siax-system.net` |
| `GOOGLE_CLIENT_ID` | Client ID de Google OAuth | (opcional) |
| `GOOGLE_CLIENT_SECRET` | Client Secret de Google OAuth | (opcional) |
## Licencia
---
Propiedad de Telcotronics. Todos los derechos reservados.
## Pruebas automatizadas
```bash
go test -v ./...
```
+54 -47
View File
@@ -1,4 +1,4 @@
# agents.md — Punto de entrada del proyecto
# agents.md — Punto de entrada del proyecto: app_compartir
> **Reglas:**
> - Este archivo es el eslabón más importante. Leerlo siempre antes de cualquier tarea.
@@ -11,16 +11,20 @@
## Antecedentes
[COMPLETAR — contexto del problema que resuelve este proyecto, por qué existe dentro del ecosistema Telcotronics]
Compartir archivos, fotos, PDFs, notas o fragmentos de código entre dispositivos (móvil y PC) suele terminar dependiendo de clientes de mensajería como WhatsApp, lo cual es invasivo, incómodo y comprime o altera contenidos. `app_compartir` resuelve esto proveyendo una webapp ligera y directa para transferir y visualizar contenido al instante con enlace directo y código QR.
---
## Objetivo general
[COMPLETAR — qué hace el sistema y cuáles son sus responsabilidades principales]
Proporcionar una aplicación web ágil en Go para transferir, compartir y previsualizar archivos y textos sin fricción, con soporte de sesiones invitadas efímeras y cuentas registradas con almacenamiento permanente.
1. [COMPLETAR — responsabilidad 1]
2. [COMPLETAR — responsabilidad 2]
1. **Compartición directa:** Subida mediante Drag & Drop, selección de archivos, pegado desde portapapeles (`Ctrl+V`) o editor rápido de texto/código.
2. **Generación de enlaces y QR:** Obtención inmediata de enlace para compartir y código QR para apertura instantánea en dispositivos móviles.
3. **Visor universal integrado (`/v/{id}`):** Vista previa nativa de imágenes, videos, audios, PDFs, textos y código, además de descarga directa para otros tipos de archivo.
4. **Política de retención diferenciada:**
- **Modo Guest (Invitado):** No requiere login. El contenido subido se **elimina automáticamente tras 24 horas** de su subida (limpieza de archivo físico y registro en base de datos).
- **Cuentas registradas (Google OAuth y WebControl):** Almacenamiento **permanente** (no se elimina automáticamente a las 24h). El usuario puede listar sus archivos compartidos y eliminarlos manualmente cuando desee.
---
@@ -28,19 +32,34 @@
| Componente | Tecnología |
|---|---|
| Lenguaje | Go |
| Framework | [COMPLETAR] |
| Base de datos | [COMPLETAR] |
| [Otros] | [COMPLETAR] |
| Lenguaje | Go 1.22+ / 1.27 |
| Router / HTTP | `net/http` estándar con enrutamiento nativo |
| Base de datos | SQLite (`modernc.org/sqlite` o `mattn/go-sqlite3`) |
| Frontend | HTML5 + Tailwind CSS + Vanilla JS / QR generator SVG |
| Autenticación | Sesiones cookies HTTP-only, WebControl (`api.siax-system.net`) y Google OAuth2 |
| Almacenamiento | Almacenamiento local en disco (`./uploads/`) con limpieza concurrente |
---
## Módulos del sistema
### 1. [Nombre del módulo]
### 1. Motor de Almacenamiento y Uploads (`storage`)
**Estado: ⏳ Pendiente**
Manejo de archivos multipart, validación de tamaños, generación de IDs únicos (slugs) y persistencia en `./uploads/`.
[Descripción del módulo]
### 2. Visor Universal (`viewer`)
**Estado: ⏳ Pendiente**
Interfaz de visualización responsive con detección de tipo MIME: imágenes con zoom, video/audio HTML5, visor PDF embebido, visualizador de texto/código con botón de copiar y QR de acceso rápido.
### 3. Autenticación y Sesiones (`auth`)
**Estado: ⏳ Pendiente**
- Sesión Guest por defecto (identificador anónimo/cookie).
- Integración WebControl (`https://api.siax-system.net/api/auth/login`).
- Integración Google OAuth2.
### 4. Worker de Limpieza 24h (`cleaner`)
**Estado: ⏳ Pendiente**
Goroutine periódica (ticker) que consulta items donde `is_guest = true AND expires_at <= NOW()`, borrando el archivo físico del disco y la fila de SQLite. Las cuentas registradas son ignoradas por el limpiador.
---
@@ -54,54 +73,36 @@
| Parámetro | Valor |
|---|---|
| Dominio público | [COMPLETAR] |
| Puerto interno | [COMPLETAR] |
| Servicio systemd | [COMPLETAR] |
| BD principal | [COMPLETAR] |
| Dominio público | Por definir (ej: `compartir.sigmac.app` / `compartir.telcotronics.net`) |
| Puerto interno | `8085` (o configurable vía `PORT`) |
| Servicio systemd | `app-compartir.service` |
| BD principal | SQLite local (`data/compartir.db`) |
| MCP ecosistema | `mcp.telcotronics.net` 🔧 en mantenimiento |
> **Correo del ecosistema:** usar `no-reply@sigmac.app` con contraseña `Sigma.2030@`
> vía `smtp.sigmac.app:587` (STARTTLS). Desde LAN interna usar `192.168.10.111:587`.
> Credenciales disponibles en `.agente/infraestructura.md` — sección servidor-email.
> Para el detalle completo y datos del ecosistema ver `.agente/infraestructura.md`.
## Documentos del ecosistema (referencia)
> Estos documentos viven en `/home/pablinux/Projects/` y son la fuente de verdad del ecosistema.
> **No duplicar su contenido aquí** — referenciarlos.
| Documento | Descripción |
|---|---|
| `~/Projects/design-system.md` | Sistema de diseño unificado — colores, tipografía, componentes para Flutter / Vue / Java / PHP |
| `~/Projects/roadmap.md` | Hoja de ruta estratégica — decisiones técnicas tomadas y pendientes de ejecutar |
| `~/Projects/infraestructura.md` | Arquitectura completa de servidores, red, CTs y servicios del ecosistema |
| `~/Projects/prompt_actualizacion.md` | Protocolo de actualización — registrar objetivos, documentar sesiones, sincronizar |
---
## Variables de entorno (`.env`)
| Variable | Descripción |
|---|---|
| [COMPLETAR] | [COMPLETAR] |
---
## Migraciones
Runner: `[COMPLETAR]`
| Archivo | Tabla principal | Estado |
| Variable | Descripción | Default |
|---|---|---|
| — | — | — |
| `PORT` | Puerto de escucha HTTP | `8085` |
| `BASE_URL` | URL base pública de la app | `http://localhost:8085` |
| `DATA_DIR` | Directorio para base de datos y uploads | `./data` |
| `SESSION_SECRET` | Clave secreta para cookies de sesión | `compartir-secret-key-change-me` |
| `GOOGLE_CLIENT_ID` | Client ID de Google OAuth | (opcional) |
| `GOOGLE_CLIENT_SECRET` | Client Secret de Google OAuth | (opcional) |
| `WEBCONTROL_API_URL` | URL de autenticación de WebControl | `https://api.siax-system.net/api/auth/login` |
---
## Para arrancar en local
```bash
# [COMPLETAR — pasos mínimos para correr el proyecto]
# Descargar dependencias
go mod tidy
# Ejecutar el servidor
go run main.go
```
---
@@ -112,6 +113,8 @@ Runner: `[COMPLETAR]`
app_compartir/
├── README.md
├── agents.md # ← LEER PRIMERO
├── go.mod
├── main.go
└── .agente/
├── infraestructura.md # Ecosistema — espejo del global
├── proyecto_estructura.md # Árbol de archivos y rutas
@@ -127,4 +130,8 @@ app_compartir/
| Módulo | Estado |
|---|---|
| [COMPLETAR] | ⏳ Pendiente |
| Módulo de subida y almacenamiento | ✅ Completo |
| Visor integrado (imágenes, video, audio, PDF, texto) | ✅ Completo |
| Purga automática 24h para cuentas Guest | ✅ Completo |
| Autenticación Guest / WebControl / Google | ✅ Completo |
| Panel de gestión para usuarios registrados | ✅ Completo |
+22
View File
@@ -0,0 +1,22 @@
module app_compartir
go 1.27.1
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/oauth2 v0.37.0
modernc.org/sqlite v1.60.1
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+56
View File
@@ -0,0 +1,56 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+300
View File
@@ -0,0 +1,300 @@
package auth
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"app_compartir/internal/config"
"app_compartir/internal/db"
"app_compartir/internal/models"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const (
CookieSessionName = "compartir_session"
)
type AuthService struct {
cfg *config.Config
db *db.Database
oauthConfig *oauth2.Config
}
func New(cfg *config.Config, database *db.Database) *AuthService {
var oauthConf *oauth2.Config
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
oauthConf = &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: cfg.BaseURL + "/auth/google/callback",
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
},
Endpoint: google.Endpoint,
}
}
return &AuthService{
cfg: cfg,
db: database,
oauthConfig: oauthConf,
}
}
func (a *AuthService) GenerateToken(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(bytes)
}
func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request) *models.Session {
cookie, err := r.Cookie(CookieSessionName)
if err == nil && cookie.Value != "" {
session, err := a.db.GetSession(cookie.Value)
if err == nil && session != nil {
return session
}
}
// Crear nueva sesión de invitado (Guest)
token := a.GenerateToken(32)
guestID := "guest_" + a.GenerateToken(8)
now := time.Now()
expiresAt := now.Add(30 * 24 * time.Hour) // La cookie de sesión dura 30 días, pero los archivos del guest expiran en 24h
session := &models.Session{
Token: token,
UserID: nil,
IsGuest: true,
GuestID: guestID,
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := a.db.CreateSession(session); err != nil {
return session
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: token,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return session
}
func (a *AuthService) AttachUserToSession(w http.ResponseWriter, r *http.Request, user *models.User) error {
token := a.GenerateToken(32)
now := time.Now()
expiresAt := now.Add(30 * 24 * time.Hour)
session := &models.Session{
Token: token,
UserID: &user.ID,
IsGuest: false,
GuestID: "",
CreatedAt: now,
ExpiresAt: expiresAt,
User: user,
}
if err := a.db.CreateSession(session); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: token,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (a *AuthService) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(CookieSessionName); err == nil && cookie.Value != "" {
_ = a.db.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
})
}
// LoginWebControl realiza autenticación con WebControl de Telcotronics
func (a *AuthService) LoginWebControl(email, password string) (*models.User, error) {
baseURL := strings.TrimRight(a.cfg.WebControlURL, "/")
// 1. Consultar empresas disponibles para el usuario
empresasURL := baseURL + "/api/auth/empresas"
empReqBody, _ := json.Marshal(map[string]string{
"email": email,
"paswd": password,
})
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Post(empresasURL, "application/json", bytes.NewBuffer(empReqBody))
dba := ""
if err == nil && resp.StatusCode == http.StatusOK {
var empData struct {
Empresas []struct {
DBA string `json:"dba"`
Nombre string `json:"nombre"`
} `json:"empresas"`
}
if err := json.NewDecoder(resp.Body).Decode(&empData); err == nil && len(empData.Empresas) > 0 {
dba = empData.Empresas[0].DBA
}
resp.Body.Close()
}
// 2. Realizar login final
loginURL := baseURL + "/api/auth/login"
loginPayload := map[string]string{
"email": email,
"paswd": password,
}
if dba != "" {
loginPayload["dba"] = dba
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := client.Post(loginURL, "application/json", bytes.NewBuffer(loginBody))
if err != nil {
return nil, fmt.Errorf("error conectando con WebControl: %w", err)
}
defer loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(loginResp.Body)
return nil, fmt.Errorf("credenciales inválidas en WebControl (status %d): %s", loginResp.StatusCode, string(bodyBytes))
}
var res struct {
Token string `json:"token"`
APIKey string `json:"apiKey"`
Usuario struct {
ID interface{} `json:"id"`
Nombre string `json:"nombre"`
Email string `json:"email"`
} `json:"usuario"`
}
if err := json.NewDecoder(loginResp.Body).Decode(&res); err != nil {
return nil, fmt.Errorf("error interpretando respuesta de WebControl: %w", err)
}
userIDStr := fmt.Sprintf("wc_%v", res.Usuario.ID)
if res.Usuario.ID == nil || userIDStr == "wc_<nil>" {
userIDStr = "wc_" + a.GenerateToken(8)
}
userName := res.Usuario.Nombre
if userName == "" {
userName = strings.Split(email, "@")[0]
}
userEmail := res.Usuario.Email
if userEmail == "" {
userEmail = email
}
user := &models.User{
ID: userIDStr,
Provider: "webcontrol",
ProviderID: fmt.Sprintf("%v", res.Usuario.ID),
Email: userEmail,
Name: userName,
AvatarURL: "",
CreatedAt: time.Now(),
}
if err := a.db.UpsertUser(user); err != nil {
return nil, fmt.Errorf("error guardando usuario en base de datos: %w", err)
}
return user, nil
}
// Google OAuth
func (a *AuthService) GetGoogleAuthURL(state string) (string, error) {
if a.oauthConfig == nil {
return "", fmt.Errorf("Google OAuth no está configurado (faltan GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)")
}
return a.oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
func (a *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.User, error) {
if a.oauthConfig == nil {
return nil, fmt.Errorf("Google OAuth no está configurado")
}
token, err := a.oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("error intercambiando código OAuth: %w", err)
}
client := a.oauthConfig.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("error obteniendo perfil de Google: %w", err)
}
defer resp.Body.Close()
var gUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&gUser); err != nil {
return nil, fmt.Errorf("error leyendo perfil de Google: %w", err)
}
userID := "google_" + gUser.ID
user := &models.User{
ID: userID,
Provider: "google",
ProviderID: gUser.ID,
Email: gUser.Email,
Name: gUser.Name,
AvatarURL: gUser.Picture,
CreatedAt: time.Now(),
}
if err := a.db.UpsertUser(user); err != nil {
return nil, fmt.Errorf("error guardando usuario de Google: %w", err)
}
return user, nil
}
func (a *AuthService) HasGoogleConfigured() bool {
return a.oauthConfig != nil
}
+84
View File
@@ -0,0 +1,84 @@
package cleaner
import (
"log"
"os"
"path/filepath"
"time"
"app_compartir/internal/db"
)
type Cleaner struct {
db *db.Database
uploadDir string
interval time.Duration
stopChan chan struct{}
}
func New(database *db.Database, uploadDir string, interval time.Duration) *Cleaner {
if interval <= 0 {
interval = 10 * time.Minute
}
return &Cleaner{
db: database,
uploadDir: uploadDir,
interval: interval,
stopChan: make(chan struct{}),
}
}
func (c *Cleaner) Start() {
log.Printf("[CLEANER] Iniciando servicio de purga automática de archivos Guest (intervalo: %v)", c.interval)
// Ejecutar una vez al inicio
c.runPurge()
go func() {
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.runPurge()
case <-c.stopChan:
log.Println("[CLEANER] Servicio de purga detenido.")
return
}
}
}()
}
func (c *Cleaner) Stop() {
close(c.stopChan)
}
func (c *Cleaner) runPurge() {
expiredShares, err := c.db.GetExpiredGuestShares()
if err != nil {
log.Printf("[CLEANER ERROR] Error consultando archivos de invitados expirados: %v", err)
return
}
for _, s := range expiredShares {
// Eliminar archivo físico si existe
if s.StoredPath != "" {
fullPath := filepath.Join(c.uploadDir, s.StoredPath)
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
log.Printf("[CLEANER ADVERTENCIA] No se pudo borrar archivo físico %s: %v", fullPath, err)
}
}
// Eliminar registro de BD (solo afecta a registros con is_guest = 1)
if err := c.db.PurgeShare(s.ID); err != nil {
log.Printf("[CLEANER ERROR] No se pudo purgar registro de share %s: %v", s.ID, err)
} else {
log.Printf("[CLEANER] ✅ Archivo de invitado expirado (24h) purgado con éxito: ID=%s", s.ID)
}
}
// Limpiar sesiones caducadas
if err := c.db.PurgeExpiredSessions(); err != nil {
log.Printf("[CLEANER ERROR] Error purgando sesiones expiradas: %v", err)
}
}
+72
View File
@@ -0,0 +1,72 @@
package config
import (
"bufio"
"os"
"strings"
)
type Config struct {
Port string
BaseURL string
DataDir string
UploadDir string
DBPath string
SessionSecret string
WebControlURL string
GoogleClientID string
GoogleClientSecret string
}
func Load() *Config {
loadDotEnv(".env")
port := getEnv("PORT", "8085")
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
dataDir := getEnv("DATA_DIR", "./data")
uploadDir := dataDir + "/uploads"
dbPath := dataDir + "/compartir.db"
return &Config{
Port: port,
BaseURL: strings.TrimRight(baseURL, "/"),
DataDir: dataDir,
UploadDir: uploadDir,
DBPath: dbPath,
SessionSecret: getEnv("SESSION_SECRET", "super-secret-compartir-key-2026"),
WebControlURL: getEnv("WEBCONTROL_API_URL", "https://api.siax-system.net"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
}
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
func loadDotEnv(filepath string) {
file, err := os.Open(filepath)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
k := strings.TrimSpace(parts[0])
v := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
if os.Getenv(k) == "" {
os.Setenv(k, v)
}
}
}
}
+329
View File
@@ -0,0 +1,329 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"app_compartir/internal/models"
_ "modernc.org/sqlite"
)
type Database struct {
*sql.DB
}
func Init(dbPath, uploadDir string) (*Database, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("error creando directorio de bd: %w", err)
}
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("error creando directorio de uploads: %w", err)
}
conn, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, fmt.Errorf("error abriendo sqlite: %w", err)
}
d := &Database{DB: conn}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("error ejecutando migraciones: %w", err)
}
return d, nil
}
func (d *Database) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
email TEXT,
name TEXT,
avatar_url TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT,
is_guest BOOLEAN NOT NULL DEFAULT 1,
guest_id TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
user_id TEXT,
user_display TEXT NOT NULL DEFAULT 'Invitado',
is_guest BOOLEAN NOT NULL DEFAULT 1,
type TEXT NOT NULL DEFAULT 'file',
title TEXT NOT NULL,
stored_path TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT 'text/plain',
text_content TEXT,
views_count INTEGER NOT NULL DEFAULT 0,
downloads_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_shares_expired_guest ON shares(is_guest, expires_at);
CREATE INDEX IF NOT EXISTS idx_shares_user_id ON shares(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
`
_, err := d.Exec(schema)
return err
}
func (d *Database) CreateShare(s *models.Share) error {
query := `
INSERT INTO shares (
id, user_id, user_display, is_guest, type, title, stored_path,
file_size, mime_type, text_content, views_count, downloads_count,
created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := d.Exec(query,
s.ID, s.UserID, s.UserDisplay, s.IsGuest, s.Type, s.Title, s.StoredPath,
s.FileSize, s.MimeType, s.TextContent, s.ViewsCount, s.DownloadsCount,
s.CreatedAt, s.ExpiresAt,
)
return err
}
func (d *Database) GetShare(id string) (*models.Share, error) {
query := `
SELECT id, user_id, user_display, is_guest, type, title, stored_path,
file_size, mime_type, text_content, views_count, downloads_count,
created_at, expires_at
FROM shares WHERE id = ?`
row := d.QueryRow(query, id)
s := &models.Share{}
var expiresAt sql.NullTime
var textContent, storedPath sql.NullString
var userID sql.NullString
err := row.Scan(
&s.ID, &userID, &s.UserDisplay, &s.IsGuest, &s.Type, &s.Title, &storedPath,
&s.FileSize, &s.MimeType, &textContent, &s.ViewsCount, &s.DownloadsCount,
&s.CreatedAt, &expiresAt,
)
if err != nil {
return nil, err
}
if userID.Valid {
s.UserID = &userID.String
}
if storedPath.Valid {
s.StoredPath = storedPath.String
}
if textContent.Valid {
s.TextContent = textContent.String
}
if expiresAt.Valid {
s.ExpiresAt = &expiresAt.Time
}
return s, nil
}
func (d *Database) IncrementViews(id string) error {
_, err := d.Exec("UPDATE shares SET views_count = views_count + 1 WHERE id = ?", id)
return err
}
func (d *Database) IncrementDownloads(id string) error {
_, err := d.Exec("UPDATE shares SET downloads_count = downloads_count + 1 WHERE id = ?", id)
return err
}
func (d *Database) GetUserShares(userID string) ([]*models.Share, error) {
query := `
SELECT id, user_id, user_display, is_guest, type, title, stored_path,
file_size, mime_type, text_content, views_count, downloads_count,
created_at, expires_at
FROM shares WHERE user_id = ? ORDER BY created_at DESC`
rows, err := d.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*models.Share
for rows.Next() {
s := &models.Share{}
var expiresAt sql.NullTime
var textContent, storedPath sql.NullString
var uID sql.NullString
if err := rows.Scan(
&s.ID, &uID, &s.UserDisplay, &s.IsGuest, &s.Type, &s.Title, &storedPath,
&s.FileSize, &s.MimeType, &textContent, &s.ViewsCount, &s.DownloadsCount,
&s.CreatedAt, &expiresAt,
); err != nil {
return nil, err
}
if uID.Valid {
s.UserID = &uID.String
}
if storedPath.Valid {
s.StoredPath = storedPath.String
}
if textContent.Valid {
s.TextContent = textContent.String
}
if expiresAt.Valid {
s.ExpiresAt = &expiresAt.Time
}
list = append(list, s)
}
return list, nil
}
func (d *Database) DeleteShare(id string, userID *string) error {
if userID != nil {
_, err := d.Exec("DELETE FROM shares WHERE id = ? AND user_id = ?", id, *userID)
return err
}
_, err := d.Exec("DELETE FROM shares WHERE id = ?", id)
return err
}
// GetExpiredGuestShares obtiene SOLO los shares marcados como guest cuya fecha de expiración ya pasó.
// Los usuarios registrados (is_guest = 0) NUNCA se seleccionan aquí.
func (d *Database) GetExpiredGuestShares() ([]*models.Share, error) {
query := `
SELECT id, stored_path
FROM shares
WHERE is_guest = 1 AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*models.Share
for rows.Next() {
s := &models.Share{}
var storedPath sql.NullString
if err := rows.Scan(&s.ID, &storedPath); err != nil {
return nil, err
}
if storedPath.Valid {
s.StoredPath = storedPath.String
}
list = append(list, s)
}
return list, nil
}
func (d *Database) PurgeShare(id string) error {
_, err := d.Exec("DELETE FROM shares WHERE id = ? AND is_guest = 1", id)
return err
}
func (d *Database) UpsertUser(u *models.User) error {
query := `
INSERT INTO users (id, provider, provider_id, email, name, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
name = excluded.name,
avatar_url = excluded.avatar_url`
_, err := d.Exec(query, u.ID, u.Provider, u.ProviderID, u.Email, u.Name, u.AvatarURL, u.CreatedAt)
return err
}
func (d *Database) GetUser(id string) (*models.User, error) {
query := `SELECT id, provider, provider_id, email, name, avatar_url, created_at FROM users WHERE id = ?`
row := d.QueryRow(query, id)
u := &models.User{}
var email, name, avatar sql.NullString
if err := row.Scan(&u.ID, &u.Provider, &u.ProviderID, &email, &name, &avatar, &u.CreatedAt); err != nil {
return nil, err
}
if email.Valid {
u.Email = email.String
}
if name.Valid {
u.Name = name.String
}
if avatar.Valid {
u.AvatarURL = avatar.String
}
return u, nil
}
func (d *Database) CreateSession(s *models.Session) error {
query := `
INSERT INTO sessions (token, user_id, is_guest, guest_id, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`
_, err := d.Exec(query, s.Token, s.UserID, s.IsGuest, s.GuestID, s.CreatedAt, s.ExpiresAt)
return err
}
func (d *Database) GetSession(token string) (*models.Session, error) {
query := `
SELECT s.token, s.user_id, s.is_guest, s.guest_id, s.created_at, s.expires_at,
u.id, u.provider, u.provider_id, u.email, u.name, u.avatar_url, u.created_at
FROM sessions s
LEFT JOIN users u ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > CURRENT_TIMESTAMP`
row := d.QueryRow(query, token)
s := &models.Session{}
var userID sql.NullString
var uID, provider, provID, email, name, avatar sql.NullString
var uCreated sql.NullTime
err := row.Scan(
&s.Token, &userID, &s.IsGuest, &s.GuestID, &s.CreatedAt, &s.ExpiresAt,
&uID, &provider, &provID, &email, &name, &avatar, &uCreated,
)
if err != nil {
return nil, err
}
if userID.Valid {
s.UserID = &userID.String
}
if uID.Valid {
s.User = &models.User{
ID: uID.String,
Provider: provider.String,
ProviderID: provID.String,
Email: email.String,
Name: name.String,
AvatarURL: avatar.String,
CreatedAt: uCreated.Time,
}
}
return s, nil
}
func (d *Database) DeleteSession(token string) error {
_, err := d.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
}
func (d *Database) PurgeExpiredSessions() error {
_, err := d.Exec("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP")
return err
}
+595
View File
@@ -0,0 +1,595 @@
package handlers
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"app_compartir/internal/auth"
"app_compartir/internal/config"
"app_compartir/internal/db"
"app_compartir/internal/models"
"app_compartir/internal/storage"
"github.com/skip2/go-qrcode"
)
type Handlers struct {
cfg *config.Config
db *db.Database
auth *auth.AuthService
templates map[string]*template.Template
}
func New(cfg *config.Config, database *db.Database, authSvc *auth.AuthService) (*Handlers, error) {
h := &Handlers{
cfg: cfg,
db: database,
auth: authSvc,
templates: make(map[string]*template.Template),
}
if err := h.parseTemplates(); err != nil {
return nil, err
}
return h, nil
}
func (h *Handlers) parseTemplates() error {
pages := []string{"index.html", "viewer.html", "login.html", "my_shares.html", "expired.html"}
basePath := "web/templates/base.html"
for _, page := range pages {
pagePath := filepath.Join("web/templates", page)
tmpl, err := template.ParseFiles(basePath, pagePath)
if err != nil {
return fmt.Errorf("error parseando plantilla %s: %w", page, err)
}
h.templates[page] = tmpl
}
return nil
}
type PageData struct {
Session *models.Session
Share *models.Share
Shares []*models.Share
FormattedDate string
IsImage bool
IsVideo bool
IsAudio bool
IsPDF bool
IsText bool
HasGoogle bool
Error string
}
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
session := h.auth.GetOrCreateSession(w, r)
data := PageData{
Session: session,
}
tmpl := h.templates["index.html"]
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
log.Printf("Error renderizando index.html: %v", err)
http.Error(w, "Error interno", http.StatusInternalServerError)
}
}
func (h *Handlers) Upload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Método no permitido", http.StatusMethodNotAllowed)
return
}
// Límite de 500 MB por subida
r.Body = http.MaxBytesReader(w, r.Body, 500<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "Archivo demasiado grande o formulario inválido", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "No se encontró el archivo en la petición", http.StatusBadRequest)
return
}
defer file.Close()
session := h.auth.GetOrCreateSession(w, r)
slug := storage.GenerateSlug(7)
storedPath, written, err := storage.SaveFile(h.cfg.UploadDir, slug, header.Filename, file)
if err != nil {
log.Printf("Error guardando archivo en disco: %v", err)
http.Error(w, "Error guardando archivo en el servidor", http.StatusInternalServerError)
return
}
// Detectar tipo MIME
mimeType := storage.DetectMimeType(header.Filename, nil)
now := time.Now()
var expiresAt *time.Time
var userID *string
userDisplay := "Invitado"
if session.IsGuest {
// REGLA FUNDAMENTAL: Solo los invitados expiran en 24 horas
exp := now.Add(24 * time.Hour)
expiresAt = &exp
} else if session.User != nil {
// Las cuentas registradas son PERMANENTES
expiresAt = nil
userID = &session.User.ID
userDisplay = session.User.Name
if userDisplay == "" {
userDisplay = session.User.Email
}
}
share := &models.Share{
ID: slug,
UserID: userID,
UserDisplay: userDisplay,
IsGuest: session.IsGuest,
Type: "file",
Title: header.Filename,
StoredPath: storedPath,
FileSize: written,
MimeType: mimeType,
TextContent: "",
ViewsCount: 0,
DownloadsCount: 0,
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := h.db.CreateShare(share); err != nil {
log.Printf("Error registrando share en BD: %v", err)
_ = os.Remove(filepath.Join(h.cfg.UploadDir, storedPath))
http.Error(w, "Error registrando archivo en base de datos", http.StatusInternalServerError)
return
}
resp := map[string]interface{}{
"id": share.ID,
"title": share.Title,
"file_size": share.FileSize,
"mime_type": share.MimeType,
"is_guest": share.IsGuest,
"viewer_url": fmt.Sprintf("%s/v/%s", h.cfg.BaseURL, share.ID),
"download_url": fmt.Sprintf("%s/d/%s", h.cfg.BaseURL, share.ID),
}
if share.IsGuest {
resp["expires_in"] = "24 horas"
} else {
resp["expires_in"] = "Permanente"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (h *Handlers) ShareText(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Método no permitido", http.StatusMethodNotAllowed)
return
}
var req struct {
Title string `json:"title"`
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" {
http.Error(w, "Contenido de texto requerido", http.StatusBadRequest)
return
}
session := h.auth.GetOrCreateSession(w, r)
slug := storage.GenerateSlug(7)
now := time.Now()
title := strings.TrimSpace(req.Title)
if title == "" {
// Generar título a partir de la primera línea de texto
lines := strings.Split(req.Text, "\n")
firstLine := strings.TrimSpace(lines[0])
if len(firstLine) > 40 {
firstLine = firstLine[:40] + "..."
}
if firstLine != "" {
title = firstLine
} else {
title = "Nota de texto"
}
}
var expiresAt *time.Time
var userID *string
userDisplay := "Invitado"
if session.IsGuest {
// REGLA: Los invitados expiran a las 24 horas
exp := now.Add(24 * time.Hour)
expiresAt = &exp
} else if session.User != nil {
// Los registrados son PERMANENTES
expiresAt = nil
userID = &session.User.ID
userDisplay = session.User.Name
if userDisplay == "" {
userDisplay = session.User.Email
}
}
share := &models.Share{
ID: slug,
UserID: userID,
UserDisplay: userDisplay,
IsGuest: session.IsGuest,
Type: "text",
Title: title,
StoredPath: "",
FileSize: int64(len(req.Text)),
MimeType: "text/plain; charset=utf-8",
TextContent: req.Text,
ViewsCount: 0,
DownloadsCount: 0,
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := h.db.CreateShare(share); err != nil {
log.Printf("Error registrando share de texto en BD: %v", err)
http.Error(w, "Error guardando texto en base de datos", http.StatusInternalServerError)
return
}
resp := map[string]interface{}{
"id": share.ID,
"title": share.Title,
"file_size": share.FileSize,
"mime_type": share.MimeType,
"is_guest": share.IsGuest,
"viewer_url": fmt.Sprintf("%s/v/%s", h.cfg.BaseURL, share.ID),
"download_url": fmt.Sprintf("%s/d/%s", h.cfg.BaseURL, share.ID),
}
if share.IsGuest {
resp["expires_in"] = "24 horas"
} else {
resp["expires_in"] = "Permanente"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (h *Handlers) View(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/v/")
if id == "" {
http.NotFound(w, r)
return
}
share, err := h.db.GetShare(id)
if err != nil || share == nil {
h.renderExpired(w, r)
return
}
// Verificar si ya expiró (en caso de que el worker aún no lo haya borrado)
if share.IsGuest && share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now()) {
h.renderExpired(w, r)
return
}
_ = h.db.IncrementViews(id)
share.ViewsCount++
session := h.auth.GetOrCreateSession(w, r)
data := PageData{
Session: session,
Share: share,
FormattedDate: share.CreatedAt.Format("02/01/2006 15:04"),
IsImage: strings.HasPrefix(share.MimeType, "image/"),
IsVideo: strings.HasPrefix(share.MimeType, "video/"),
IsAudio: strings.HasPrefix(share.MimeType, "audio/"),
IsPDF: share.MimeType == "application/pdf",
IsText: strings.HasPrefix(share.MimeType, "text/") || share.Type == "text",
}
tmpl := h.templates["viewer.html"]
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
log.Printf("Error renderizando visor: %v", err)
http.Error(w, "Error interno", http.StatusInternalServerError)
}
}
func (h *Handlers) renderExpired(w http.ResponseWriter, r *http.Request) {
session := h.auth.GetOrCreateSession(w, r)
data := PageData{
Session: session,
}
tmpl := h.templates["expired.html"]
w.WriteHeader(http.StatusNotFound)
_ = tmpl.ExecuteTemplate(w, "base.html", data)
}
func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/d/")
if id == "" {
http.NotFound(w, r)
return
}
share, err := h.db.GetShare(id)
if err != nil || share == nil {
h.renderExpired(w, r)
return
}
if share.IsGuest && share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now()) {
h.renderExpired(w, r)
return
}
_ = h.db.IncrementDownloads(id)
if share.Type == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", share.Title+".txt"))
_, _ = w.Write([]byte(share.TextContent))
return
}
fullPath := filepath.Join(h.cfg.UploadDir, share.StoredPath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
h.renderExpired(w, r)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", share.Title))
http.ServeFile(w, r, fullPath)
}
func (h *Handlers) Raw(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/raw/")
if id == "" {
http.NotFound(w, r)
return
}
share, err := h.db.GetShare(id)
if err != nil || share == nil {
http.NotFound(w, r)
return
}
if share.IsGuest && share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now()) {
http.NotFound(w, r)
return
}
if share.Type == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(share.TextContent))
return
}
fullPath := filepath.Join(h.cfg.UploadDir, share.StoredPath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", share.MimeType)
w.Header().Set("Content-Disposition", "inline")
http.ServeFile(w, r, fullPath)
}
func (h *Handlers) QR(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/qr/")
if id == "" {
http.NotFound(w, r)
return
}
targetURL := fmt.Sprintf("%s/v/%s", h.cfg.BaseURL, id)
pngBytes, err := qrcode.Encode(targetURL, qrcode.Medium, 256)
if err != nil {
http.Error(w, "Error generando QR", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(pngBytes)
}
func (h *Handlers) LoginView(w http.ResponseWriter, r *http.Request) {
session := h.auth.GetOrCreateSession(w, r)
if !session.IsGuest && session.User != nil {
http.Redirect(w, r, "/mis-archivos", http.StatusFound)
return
}
data := PageData{
Session: session,
HasGoogle: h.auth.HasGoogleConfigured(),
}
tmpl := h.templates["login.html"]
_ = tmpl.ExecuteTemplate(w, "base.html", data)
}
func (h *Handlers) WebControlLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
email := strings.TrimSpace(r.FormValue("email"))
password := strings.TrimSpace(r.FormValue("password"))
if email == "" || password == "" {
h.renderLoginError(w, r, "Por favor ingresa usuario y contraseña.")
return
}
user, err := h.auth.LoginWebControl(email, password)
if err != nil {
log.Printf("Fallo login WebControl: %v", err)
h.renderLoginError(w, r, "Credenciales incorrectas o servidor WebControl no disponible.")
return
}
if err := h.auth.AttachUserToSession(w, r, user); err != nil {
log.Printf("Error adjuntando sesión: %v", err)
h.renderLoginError(w, r, "Error interno estableciendo sesión.")
return
}
http.Redirect(w, r, "/mis-archivos", http.StatusFound)
}
func (h *Handlers) renderLoginError(w http.ResponseWriter, r *http.Request, msg string) {
session := h.auth.GetOrCreateSession(w, r)
data := PageData{
Session: session,
HasGoogle: h.auth.HasGoogleConfigured(),
Error: msg,
}
tmpl := h.templates["login.html"]
_ = tmpl.ExecuteTemplate(w, "base.html", data)
}
func (h *Handlers) GoogleLogin(w http.ResponseWriter, r *http.Request) {
state := h.auth.GenerateToken(16)
// Guardar state en cookie temporal
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
Expires: time.Now().Add(10 * time.Minute),
HttpOnly: true,
})
url, err := h.auth.GetGoogleAuthURL(state)
if err != nil {
h.renderLoginError(w, r, err.Error())
return
}
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func (h *Handlers) GoogleCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
h.renderLoginError(w, r, "Error de verificación CSRF en login con Google.")
return
}
code := r.URL.Query().Get("code")
if code == "" {
h.renderLoginError(w, r, "No se recibió código de autenticación de Google.")
return
}
user, err := h.auth.HandleGoogleCallback(r.Context(), code)
if err != nil {
log.Printf("Error callback Google: %v", err)
h.renderLoginError(w, r, "No se pudo completar el inicio de sesión con Google.")
return
}
if err := h.auth.AttachUserToSession(w, r, user); err != nil {
h.renderLoginError(w, r, "Error interno estableciendo sesión.")
return
}
http.Redirect(w, r, "/mis-archivos", http.StatusFound)
}
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
h.auth.Logout(w, r)
http.Redirect(w, r, "/", http.StatusFound)
}
func (h *Handlers) MyShares(w http.ResponseWriter, r *http.Request) {
session := h.auth.GetOrCreateSession(w, r)
if session.IsGuest || session.User == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
shares, err := h.db.GetUserShares(session.User.ID)
if err != nil {
log.Printf("Error obteniendo archivos de usuario: %v", err)
shares = []*models.Share{}
}
data := PageData{
Session: session,
Shares: shares,
}
tmpl := h.templates["my_shares.html"]
_ = tmpl.ExecuteTemplate(w, "base.html", data)
}
func (h *Handlers) DeleteShare(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Método no permitido", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/shares/")
if id == "" {
http.Error(w, "ID requerido", http.StatusBadRequest)
return
}
session := h.auth.GetOrCreateSession(w, r)
if session.IsGuest || session.User == nil {
http.Error(w, "No autorizado", http.StatusUnauthorized)
return
}
share, err := h.db.GetShare(id)
if err != nil || share == nil {
http.Error(w, "Archivo no encontrado", http.StatusNotFound)
return
}
// Verificar pertenencia
if share.UserID == nil || *share.UserID != session.User.ID {
http.Error(w, "No tienes permiso para eliminar este archivo", http.StatusForbidden)
return
}
if share.StoredPath != "" {
_ = os.Remove(filepath.Join(h.cfg.UploadDir, share.StoredPath))
}
if err := h.db.DeleteShare(id, &session.User.ID); err != nil {
http.Error(w, "Error eliminando de base de datos", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
+60
View File
@@ -0,0 +1,60 @@
package models
import (
"fmt"
"time"
)
type User struct {
ID string `json:"id"`
Provider string `json:"provider"` // "webcontrol" | "google"
ProviderID string `json:"provider_id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
Token string `json:"token"`
UserID *string `json:"user_id"`
IsGuest bool `json:"is_guest"`
GuestID string `json:"guest_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
User *User `json:"user,omitempty"`
}
type Share struct {
ID string `json:"id"`
UserID *string `json:"user_id"`
UserDisplay string `json:"user_display"`
IsGuest bool `json:"is_guest"`
Type string `json:"type"` // "file" | "text"
Title string `json:"title"`
StoredPath string `json:"stored_path"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
TextContent string `json:"text_content"`
ViewsCount int `json:"views_count"`
DownloadsCount int `json:"downloads_count"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"` // NULL si registrado, NOW+24h si guest
}
func (s *Share) FormattedSize() string {
if s.FileSize == 0 {
return "0 B"
}
const unit = 1024
if s.FileSize < unit {
return fmt.Sprintf("%d B", s.FileSize)
}
div, exp := int64(unit), 0
for n := s.FileSize / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB", "TB"}
return fmt.Sprintf("%.1f %s", float64(s.FileSize)/float64(div), units[exp])
}
+121
View File
@@ -0,0 +1,121 @@
package storage
import (
"crypto/rand"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const slugChars = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
var safeFilenameRegex = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func GenerateSlug(length int) string {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "x" + strings.ToLower(slugChars[:length-1])
}
charsLen := byte(len(slugChars))
for i := 0; i < length; i++ {
b[i] = slugChars[b[i]%charsLen]
}
return string(b)
}
func SanitizeFilename(name string) string {
base := filepath.Base(name)
sanitized := safeFilenameRegex.ReplaceAllString(base, "_")
if len(sanitized) > 100 {
ext := filepath.Ext(sanitized)
sanitized = sanitized[:100-len(ext)] + ext
}
if sanitized == "" || sanitized == "." {
sanitized = "archivo.bin"
}
return sanitized
}
func DetectMimeType(filename string, reader io.ReadSeeker) string {
ext := strings.ToLower(filepath.Ext(filename))
if m := mime.TypeByExtension(ext); m != "" {
return m
}
// Sniff first 512 bytes
if reader != nil {
buf := make([]byte, 512)
n, err := reader.Read(buf)
_, _ = reader.Seek(0, io.SeekStart)
if err == nil && n > 0 {
detected := http.DetectContentType(buf[:n])
if detected != "application/octet-stream" {
return detected
}
}
}
// Known fallback extensions
switch ext {
case ".pdf":
return "application/pdf"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg":
return "audio/ogg"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".txt", ".log", ".env":
return "text/plain; charset=utf-8"
case ".json":
return "application/json"
case ".js":
return "application/javascript"
case ".go", ".py", ".rs", ".java", ".c", ".cpp", ".sh", ".sql", ".html", ".css", ".md":
return "text/plain; charset=utf-8"
default:
return "application/octet-stream"
}
}
func SaveFile(uploadDir, slug, originalFilename string, src io.Reader) (string, int64, error) {
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return "", 0, err
}
safeName := SanitizeFilename(originalFilename)
storedFilename := slug + "_" + safeName
destPath := filepath.Join(uploadDir, storedFilename)
dest, err := os.Create(destPath)
if err != nil {
return "", 0, err
}
defer dest.Close()
written, err := io.Copy(dest, src)
if err != nil {
_ = os.Remove(destPath)
return "", 0, err
}
return storedFilename, written, nil
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"app_compartir/internal/auth"
"app_compartir/internal/cleaner"
"app_compartir/internal/config"
"app_compartir/internal/db"
"app_compartir/internal/handlers"
)
func main() {
cfg := config.Load()
log.Println("==================================================")
log.Printf("🚀 Iniciando app_compartir en puerto %s", cfg.Port)
log.Printf("🌐 URL Base: %s", cfg.BaseURL)
log.Printf("📂 Almacenamiento: %s (Uploads: %s)", cfg.DataDir, cfg.UploadDir)
log.Printf("🛡️ Auth WebControl: %s", cfg.WebControlURL)
if cfg.GoogleClientID != "" {
log.Println("🔑 Google OAuth: Habilitado")
} else {
log.Println("🔑 Google OAuth: Deshabilitado (variables no configuradas)")
}
log.Println("⏰ Política de retención: Solo Guest expira en 24h. Cuentas registradas = permanente.")
log.Println("==================================================")
// Inicializar Base de Datos SQLite
database, err := db.Init(cfg.DBPath, cfg.UploadDir)
if err != nil {
log.Fatalf("Error fatal iniciando base de datos: %v", err)
}
defer database.Close()
// Iniciar servicio de purga automática cada 10 minutos para archivos guest vencidos
cleanerSvc := cleaner.New(database, cfg.UploadDir, 10*time.Minute)
cleanerSvc.Start()
defer cleanerSvc.Stop()
// Servicio de Autenticación
authSvc := auth.New(cfg, database)
// Handlers HTTP
h, err := handlers.New(cfg, database, authSvc)
if err != nil {
log.Fatalf("Error fatal configurando handlers: %v", err)
}
mux := http.NewServeMux()
// Rutas Web
mux.HandleFunc("GET /", h.Index)
mux.HandleFunc("GET /v/{id}", h.View)
mux.HandleFunc("GET /d/{id}", h.Download)
mux.HandleFunc("GET /raw/{id}", h.Raw)
mux.HandleFunc("GET /qr/{id}", h.QR)
// Subida y compartir
mux.HandleFunc("POST /api/upload", h.Upload)
mux.HandleFunc("POST /api/share-text", h.ShareText)
mux.HandleFunc("DELETE /api/shares/{id}", h.DeleteShare)
// Autenticación
mux.HandleFunc("GET /login", h.LoginView)
mux.HandleFunc("POST /login/webcontrol", h.WebControlLogin)
mux.HandleFunc("GET /auth/google", h.GoogleLogin)
mux.HandleFunc("GET /auth/google/callback", h.GoogleCallback)
mux.HandleFunc("GET /logout", h.Logout)
// Panel de usuario
mux.HandleFunc("GET /mis-archivos", h.MyShares)
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
ReadTimeout: 120 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Manejo de apagado graceful
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Error en el servidor HTTP: %v", err)
}
}()
fmt.Printf("\n⚡ Servidor escuchando en http://localhost:%s\n\n", cfg.Port)
<-stop
log.Println("\n🛑 Apagando servidor con gracia...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Error apagando servidor: %v", err)
}
log.Println("👋 Servidor apagado correctamente.")
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
"app_compartir/internal/cleaner"
"app_compartir/internal/db"
"app_compartir/internal/models"
)
func TestGuestExpirationAndRegisteredPermanence(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "test_compartir_*")
if err != nil {
t.Fatalf("error creando tmpDir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
uploadDir := filepath.Join(tmpDir, "uploads")
database, err := db.Init(dbPath, uploadDir)
if err != nil {
t.Fatalf("error inicializando db: %v", err)
}
defer database.Close()
// Crear archivo físico de prueba 1 (Guest vencido)
guestExpiredFile := "guest_exp.txt"
if err := os.WriteFile(filepath.Join(uploadDir, guestExpiredFile), []byte("contenido guest expirado"), 0644); err != nil {
t.Fatalf("error escribiendo archivo guest: %v", err)
}
// Crear archivo físico de prueba 2 (Usuario Registrado de hace 48 horas)
registeredOldFile := "user_old.txt"
if err := os.WriteFile(filepath.Join(uploadDir, registeredOldFile), []byte("contenido usuario permanente"), 0644); err != nil {
t.Fatalf("error escribiendo archivo usuario: %v", err)
}
// Crear usuario registrado en BD
user := &models.User{
ID: "user_123",
Provider: "webcontrol",
ProviderID: "123",
Email: "pablo@telcotronics.com",
Name: "Pablo",
CreatedAt: time.Now().Add(-48 * time.Hour),
}
if err := database.UpsertUser(user); err != nil {
t.Fatalf("error creando usuario: %v", err)
}
// 1. Share de Invitado: expirado (subido hace 25 horas, venció hace 1 hora)
pastExp := time.Now().Add(-1 * time.Hour)
guestShare := &models.Share{
ID: "guest1",
UserID: nil,
UserDisplay: "Invitado",
IsGuest: true,
Type: "file",
Title: "guest_doc.txt",
StoredPath: guestExpiredFile,
FileSize: 23,
MimeType: "text/plain",
CreatedAt: time.Now().Add(-25 * time.Hour),
ExpiresAt: &pastExp,
}
if err := database.CreateShare(guestShare); err != nil {
t.Fatalf("error guardando share guest: %v", err)
}
// 2. Share de Usuario Registrado: subido hace 48 horas, PERMANENTE (ExpiresAt = nil)
regShare := &models.Share{
ID: "reg1",
UserID: &user.ID,
UserDisplay: user.Name,
IsGuest: false,
Type: "file",
Title: "contrato_permanente.pdf",
StoredPath: registeredOldFile,
FileSize: 27,
MimeType: "application/pdf",
CreatedAt: time.Now().Add(-48 * time.Hour),
ExpiresAt: nil, // PERMANENTE
}
if err := database.CreateShare(regShare); err != nil {
t.Fatalf("error guardando share registrado: %v", err)
}
// Ejecutar Cleaner
cleanerSvc := cleaner.New(database, uploadDir, 1*time.Minute)
// Comprobar items a purgar
expiredGuest, err := database.GetExpiredGuestShares()
if err != nil {
t.Fatalf("error consultando expirados: %v", err)
}
if len(expiredGuest) != 1 {
t.Fatalf("esperado 1 archivo guest expirado, obtenido %d", len(expiredGuest))
}
if expiredGuest[0].ID != "guest1" {
t.Fatalf("esperado guest1 expirado, obtenido %s", expiredGuest[0].ID)
}
// Iniciar ciclo de purga
cleanerSvc.Start()
time.Sleep(100 * time.Millisecond) // esperar a que ejecute runPurge inicial
cleanerSvc.Stop()
// VERIFICACIÓN 1: El archivo de invitado DEBE haber sido borrado de BD y de disco
purgedGuestShare, err := database.GetShare("guest1")
if err == nil && purgedGuestShare != nil {
t.Fatalf("El archivo de invitado NO debió existir en BD tras la purga")
}
if _, err := os.Stat(filepath.Join(uploadDir, guestExpiredFile)); !os.IsNotExist(err) {
t.Fatalf("El archivo físico de invitado %s debió haber sido eliminado del disco", guestExpiredFile)
}
// VERIFICACIÓN 2: El archivo del usuario registrado DEBE permanencer intacto (BD y disco)
stayedRegShare, err := database.GetShare("reg1")
if err != nil || stayedRegShare == nil {
t.Fatalf("El archivo de usuario registrado se borró por error: %v", err)
}
if stayedRegShare.ExpiresAt != nil {
t.Fatalf("El archivo registrado no debe tener fecha de caducidad")
}
if _, err := os.Stat(filepath.Join(uploadDir, registeredOldFile)); os.IsNotExist(err) {
t.Fatalf("El archivo físico registrado %s fue borrado erróneamente", registeredOldFile)
}
t.Log("✅ Test completado con éxito: Solo los datos de invitado caducan en 24h; los de usuarios registrados son permanentes.")
}
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="es" class="h-full bg-slate-950 text-slate-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Compartir — Pasa archivos y texto al instante{{end}}</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0fdf4',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
}
}
}
}
}
</script>
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #0f172a;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
</style>
</head>
<body class="min-h-full flex flex-col bg-slate-950 text-slate-100 font-sans selection:bg-brand-500 selection:text-black">
<!-- Navbar -->
<header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" class="flex items-center gap-2.5 font-bold text-lg tracking-tight hover:opacity-90 transition">
<span class="w-9 h-9 rounded-xl bg-gradient-to-tr from-brand-600 to-emerald-400 flex items-center justify-center text-slate-950 font-black shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-share-nodes text-base"></i>
</span>
<span class="text-white">app<span class="text-brand-500">_compartir</span></span>
</a>
<!-- User status / Actions -->
<div class="flex items-center gap-3">
{{if .Session.IsGuest}}
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-medium">
<i class="fa-regular fa-clock"></i>
<span>Modo Invitado (caduca en 24h)</span>
</div>
<a href="/login" class="px-3.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold border border-slate-700 transition flex items-center gap-1.5">
<i class="fa-solid fa-arrow-right-to-bracket text-brand-500"></i>
<span>Iniciar Sesión</span>
</a>
{{else}}
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium">
<i class="fa-solid fa-shield-halved"></i>
<span>Almacenamiento Permanente</span>
</div>
<a href="/mis-archivos" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition flex items-center gap-1.5">
<i class="fa-solid fa-folder-open text-brand-500"></i>
<span class="hidden md:inline">Mis Archivos</span>
</a>
<div class="flex items-center gap-2 pl-2 border-l border-slate-800">
<span class="text-xs font-semibold text-slate-300 max-w-[120px] truncate">{{.Session.User.Name}}</span>
<a href="/logout" title="Cerrar sesión" class="p-1.5 text-slate-400 hover:text-rose-400 transition">
<i class="fa-solid fa-power-off text-xs"></i>
</a>
</div>
{{end}}
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8">
{{block "content" .}}{{end}}
</main>
<!-- Footer -->
<footer class="border-t border-slate-900 py-6 text-center text-xs text-slate-500">
<p>Ecosistema Telcotronics • Pasa archivos, texto y capturas sin usar WhatsApp</p>
</footer>
{{block "scripts" .}}{{end}}
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{{template "base.html" .}}
{{define "title"}}Archivo Expirado — app_compartir{{end}}
{{define "content"}}
<div class="max-w-md mx-auto space-y-6 pt-12 text-center">
<div class="w-16 h-16 mx-auto rounded-3xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center text-2xl shadow-xl shadow-amber-500/10">
<i class="fa-regular fa-clock"></i>
</div>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-white">Este archivo ha caducado</h1>
<p class="text-xs sm:text-sm text-slate-400 leading-relaxed">
El archivo fue subido en <strong class="text-amber-300">Modo Invitado</strong> y de acuerdo a las políticas de privacidad y espacio de la plataforma, se eliminó automáticamente 24 horas después de su subida.
</p>
</div>
<div class="p-4 rounded-2xl bg-slate-900 border border-slate-800 text-xs text-slate-400 text-left space-y-2">
<p class="font-semibold text-slate-200"><i class="fa-solid fa-lightbulb text-amber-400 mr-1.5"></i> ¿Quieres guardar archivos para siempre?</p>
<p>Inicia sesión con tu cuenta de WebControl o Google para que tus archivos se mantengan de forma permanente sin borrarse a las 24 horas.</p>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<a href="/" class="px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-xs transition flex items-center gap-2 shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-plus"></i>
<span>Subir nuevo archivo</span>
</a>
<a href="/login" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs border border-slate-700 transition">
<span>Iniciar Sesión</span>
</a>
</div>
</div>
{{end}}
+337
View File
@@ -0,0 +1,337 @@
{{template "base.html" .}}
{{define "title"}}Compartir — Inicio{{end}}
{{define "content"}}
<div class="max-w-3xl mx-auto space-y-8">
<!-- Hero / Header -->
<div class="text-center space-y-2">
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white">
Pasa archivos y texto <span class="text-brand-500">al instante</span>
</h1>
<p class="text-slate-400 text-sm sm:text-base max-w-xl mx-auto">
Olvídate de abrir WhatsApp para pasarte fotos, PDFs o notas entre tu PC y tu teléfono. Sube y escanea el QR o copia el enlace.
</p>
</div>
<!-- Mode Selector Tabs -->
<div class="flex items-center justify-center p-1 bg-slate-900 border border-slate-800 rounded-2xl max-w-md mx-auto">
<button id="tabFilesBtn" onclick="switchTab('files')" class="flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 bg-slate-800 text-white shadow">
<i class="fa-solid fa-cloud-arrow-up text-brand-500"></i>
<span>Archivos / Fotos</span>
</button>
<button id="tabTextBtn" onclick="switchTab('text')" class="flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 text-slate-400 hover:text-white">
<i class="fa-solid fa-code text-indigo-400"></i>
<span>Texto / Código</span>
</button>
</div>
<!-- Tab 1: File Upload -->
<div id="tabFiles" class="space-y-4">
<div id="dropZone" class="relative group border-2 border-dashed border-slate-700 hover:border-brand-500/80 bg-slate-900/50 hover:bg-slate-900/90 rounded-3xl p-8 sm:p-12 text-center transition-all duration-200 cursor-pointer">
<input type="file" id="fileInput" class="hidden" multiple>
<div class="space-y-4">
<div class="w-16 h-16 mx-auto rounded-2xl bg-brand-500/10 border border-brand-500/20 text-brand-400 flex items-center justify-center text-2xl group-hover:scale-110 transition duration-200">
<i class="fa-solid fa-arrow-up-from-bracket"></i>
</div>
<div class="space-y-1">
<p class="text-base sm:text-lg font-semibold text-white">
Arrastra tus archivos aquí o <span class="text-brand-400 underline decoration-brand-500/30">haz clic para explorar</span>
</p>
<p class="text-xs text-slate-400">
Imágenes, videos, audios, PDFs, ZIPs o cualquier archivo. También puedes <kbd class="px-2 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300 font-mono text-[11px]">Ctrl+V</kbd> para pegar capturas directamente.
</p>
</div>
<div class="flex items-center justify-center gap-4 pt-2 text-xs text-slate-500">
<span class="flex items-center gap-1.5"><i class="fa-solid fa-shield text-emerald-400"></i> Encriptación TLS</span>
{{if .Session.IsGuest}}
<span class="flex items-center gap-1.5"><i class="fa-regular fa-clock text-amber-400"></i> Borrado automático en 24h</span>
{{else}}
<span class="flex items-center gap-1.5"><i class="fa-solid fa-database text-emerald-400"></i> Guardado permanente</span>
{{end}}
</div>
</div>
<!-- Uploading overlay / progress -->
<div id="uploadProgress" class="hidden absolute inset-0 bg-slate-950/90 backdrop-blur-sm rounded-3xl flex flex-col items-center justify-center p-6 space-y-4">
<div class="w-12 h-12 border-4 border-slate-700 border-t-brand-500 rounded-full animate-spin"></div>
<div class="text-center space-y-1">
<p id="progressText" class="text-sm font-semibold text-white">Subiendo archivo...</p>
<div class="w-64 bg-slate-800 rounded-full h-2 overflow-hidden">
<div id="progressBar" class="bg-brand-500 h-2 rounded-full w-0 transition-all duration-150"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab 2: Text / Code Share -->
<div id="tabText" class="hidden space-y-4">
<form id="textForm" onsubmit="handleShareText(event)" class="bg-slate-900 border border-slate-800 rounded-3xl p-6 space-y-4 shadow-xl">
<div class="space-y-1">
<label for="textTitle" class="block text-xs font-semibold text-slate-300">Título o descripción (opcional)</label>
<input type="text" id="textTitle" placeholder="Ej: Notas de reunión, comando curl, contraseña temporal..." class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition">
</div>
<div class="space-y-1">
<label for="textContent" class="block text-xs font-semibold text-slate-300">Contenido (Texto, Código, Enlaces, Markdown)</label>
<textarea id="textContent" rows="8" required placeholder="Escribe o pega aquí tu texto o código para compartirlo..." class="w-full bg-slate-950 border border-slate-800 rounded-xl p-4 text-sm font-mono text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition custom-scrollbar"></textarea>
</div>
<div class="flex items-center justify-between pt-2">
<span class="text-xs text-slate-500">
{{if .Session.IsGuest}}⏰ Caducará a las 24 horas{{else}}🔒 Se guardará de forma permanente{{end}}
</span>
<button type="submit" id="submitTextBtn" class="px-6 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-sm transition flex items-center gap-2 shadow-lg shadow-indigo-600/20">
<i class="fa-solid fa-paper-plane"></i>
<span>Compartir Texto</span>
</button>
</div>
</form>
</div>
<!-- Success Result Card (Hidden initially) -->
<div id="resultCard" class="hidden bg-slate-900 border border-slate-800 rounded-3xl p-6 sm:p-8 space-y-6 shadow-2xl animate-fade-in">
<div class="flex items-center justify-between border-b border-slate-800 pb-4">
<div class="flex items-center gap-3">
<span class="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center text-lg">
<i class="fa-solid fa-check"></i>
</span>
<div>
<h3 id="resultTitle" class="text-base font-bold text-white">¡Listo para compartir!</h3>
<p id="resultSubtitle" class="text-xs text-slate-400">Escanea el QR o copia el enlace</p>
</div>
</div>
<button onclick="resetUpload()" class="text-xs text-slate-400 hover:text-white px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 transition">
<i class="fa-solid fa-plus mr-1"></i> Subir otro
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
<!-- QR Code Section -->
<div class="bg-slate-950 p-6 rounded-2xl border border-slate-800 flex flex-col items-center justify-center space-y-3">
<div class="p-2 bg-white rounded-xl shadow-lg">
<img id="qrImage" src="" alt="Código QR para escanear" class="w-44 h-44 object-contain">
</div>
<p class="text-xs text-slate-400 text-center flex items-center gap-1.5">
<i class="fa-solid fa-mobile-screen text-brand-400"></i>
<span>Apunta con la cámara de tu móvil para abrir</span>
</p>
</div>
<!-- Link & Actions Section -->
<div class="space-y-4">
<div class="space-y-1.5">
<label class="block text-xs font-semibold text-slate-400">Enlace directo para compartir</label>
<div class="flex items-center gap-2">
<input type="text" id="shareUrlInput" readonly class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-sm font-mono text-slate-200 select-all focus:outline-none">
<button onclick="copyShareUrl()" id="copyBtn" class="px-4 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-sm transition flex items-center gap-1.5 shrink-0 shadow-lg shadow-brand-500/20">
<i class="fa-regular fa-copy"></i>
<span id="copyBtnText">Copiar</span>
</button>
</div>
</div>
<div class="flex gap-2">
<a id="openViewerBtn" href="#" target="_blank" class="flex-1 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white font-semibold text-sm transition text-center flex items-center justify-center gap-2">
<i class="fa-solid fa-eye text-brand-400"></i>
<span>Abrir Visor</span>
</a>
<a id="directDownloadBtn" href="#" class="py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 font-semibold text-sm transition flex items-center justify-center gap-2" title="Descarga directa">
<i class="fa-solid fa-download"></i>
</a>
</div>
<!-- Expiration notice -->
<div id="retentionNotice" class="p-3 rounded-xl text-xs flex items-center gap-2">
<!-- Dynamic notice text inserted via JS -->
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadProgress = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultCard = document.getElementById('resultCard');
function switchTab(tab) {
if (tab === 'files') {
document.getElementById('tabFiles').classList.remove('hidden');
document.getElementById('tabText').classList.add('hidden');
document.getElementById('tabFilesBtn').className = "flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 bg-slate-800 text-white shadow";
document.getElementById('tabTextBtn').className = "flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 text-slate-400 hover:text-white";
} else {
document.getElementById('tabFiles').classList.add('hidden');
document.getElementById('tabText').classList.remove('hidden');
document.getElementById('tabTextBtn').className = "flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 bg-slate-800 text-white shadow";
document.getElementById('tabFilesBtn').className = "flex-1 py-2 px-4 rounded-xl text-sm font-semibold transition flex items-center justify-center gap-2 text-slate-400 hover:text-white";
}
}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-brand-500', 'bg-slate-900/90');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-brand-500', 'bg-slate-900/90');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-brand-500', 'bg-slate-900/90');
if (e.dataTransfer.files.length > 0) {
uploadFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) {
uploadFile(fileInput.files[0]);
}
});
// Handle Ctrl+V paste anywhere on window
window.addEventListener('paste', (e) => {
// If active element is a textarea or text input, let default paste happen
if (document.activeElement.tagName === 'TEXTAREA' || document.activeElement.tagName === 'INPUT') {
return;
}
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (let item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
uploadFile(file);
break;
}
} else if (item.kind === 'string' && item.type === 'text/plain') {
item.getAsString((text) => {
switchTab('text');
document.getElementById('textContent').value = text;
});
}
}
});
function uploadFile(file) {
uploadProgress.classList.remove('hidden');
progressBar.style.width = '0%';
progressText.innerText = `Subiendo ${file.name}...`;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload', true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + '%';
progressText.innerText = `Subiendo ${file.name} (${percent}%)...`;
}
};
xhr.onload = () => {
uploadProgress.classList.add('hidden');
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText);
showResult(res);
} else {
alert('Error al subir el archivo: ' + xhr.responseText);
}
};
xhr.onerror = () => {
uploadProgress.classList.add('hidden');
alert('Error de red al subir el archivo.');
};
xhr.send(formData);
}
async function handleShareText(e) {
e.preventDefault();
const title = document.getElementById('textTitle').value;
const text = document.getElementById('textContent').value;
const btn = document.getElementById('submitTextBtn');
btn.disabled = true;
btn.innerHTML = `<i class="fa-solid fa-circle-notch animate-spin"></i> Compartiendo...`;
try {
const resp = await fetch('/api/share-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, text })
});
const data = await resp.json();
if (resp.ok) {
showResult(data);
} else {
alert('Error al compartir texto: ' + (data.error || 'Desconocido'));
}
} catch (err) {
alert('Error de red: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = `<i class="fa-solid fa-paper-plane"></i> <span>Compartir Texto</span>`;
}
}
function showResult(data) {
document.getElementById('tabFiles').classList.add('hidden');
document.getElementById('tabText').classList.add('hidden');
resultCard.classList.remove('hidden');
document.getElementById('resultTitle').innerText = data.title || 'Contenido compartido';
document.getElementById('shareUrlInput').value = data.viewer_url;
document.getElementById('qrImage').src = `/qr/${data.id}`;
document.getElementById('openViewerBtn').href = data.viewer_url;
document.getElementById('directDownloadBtn').href = data.download_url;
const notice = document.getElementById('retentionNotice');
if (data.is_guest) {
notice.className = "p-3 rounded-xl text-xs flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300";
notice.innerHTML = `<i class="fa-regular fa-clock text-amber-400"></i> <span><strong>Sesión Invitado:</strong> Este contenido se eliminará automáticamente en 24 horas (${data.expires_in || '24h'}).</span>`;
} else {
notice.className = "p-3 rounded-xl text-xs flex items-center gap-2 bg-emerald-500/10 border border-emerald-500/20 text-emerald-300";
notice.innerHTML = `<i class="fa-solid fa-shield text-emerald-400"></i> <span><strong>Cuenta registrada:</strong> Almacenamiento permanente. Puedes gestionarlo desde Mis Archivos.</span>`;
}
resultCard.scrollIntoView({ behavior: 'smooth' });
}
function resetUpload() {
resultCard.classList.add('hidden');
fileInput.value = '';
document.getElementById('textContent').value = '';
document.getElementById('textTitle').value = '';
switchTab('files');
}
function copyShareUrl() {
const input = document.getElementById('shareUrlInput');
input.select();
navigator.clipboard.writeText(input.value).then(() => {
const btnText = document.getElementById('copyBtnText');
btnText.innerText = '¡Copiado!';
setTimeout(() => {
btnText.innerText = 'Copiar';
}, 2500);
});
}
</script>
{{end}}
+81
View File
@@ -0,0 +1,81 @@
{{template "base.html" .}}
{{define "title"}}Iniciar Sesión — app_compartir{{end}}
{{define "content"}}
<div class="max-w-md mx-auto space-y-6 pt-4">
<div class="text-center space-y-2">
<div class="w-12 h-12 mx-auto rounded-2xl bg-gradient-to-tr from-brand-600 to-emerald-400 flex items-center justify-center text-slate-950 text-xl font-black shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-lock"></i>
</div>
<h1 class="text-2xl font-bold text-white">Acceso a app_compartir</h1>
<p class="text-xs text-slate-400">
Inicia sesión para que tus archivos se guarden de forma <strong class="text-emerald-400 font-semibold">permanente</strong> y no caduquen en 24h.
</p>
</div>
{{if .Error}}
<div class="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs flex items-center gap-2">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{{.Error}}</span>
</div>
{{end}}
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-6 sm:p-8 space-y-6 shadow-2xl">
<!-- Google OAuth Option -->
{{if .HasGoogle}}
<div>
<a href="/auth/google" class="w-full py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white font-semibold text-sm transition flex items-center justify-center gap-3">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
</svg>
<span>Continuar con Google</span>
</a>
</div>
<div class="relative flex items-center justify-center">
<div class="border-t border-slate-800 w-full"></div>
<span class="bg-slate-900 px-3 text-[11px] text-slate-500 uppercase tracking-wider font-semibold">o con WebControl</span>
</div>
{{end}}
<!-- WebControl Form -->
<form action="/login/webcontrol" method="POST" class="space-y-4">
<div class="space-y-1">
<label for="email" class="block text-xs font-semibold text-slate-300">Correo electrónico / Usuario</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3.5 flex items-center text-slate-500 text-xs">
<i class="fa-solid fa-envelope"></i>
</span>
<input type="text" id="email" name="email" required placeholder="usuario@telcotronics.com" class="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-4 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-brand-500 transition">
</div>
</div>
<div class="space-y-1">
<label for="password" class="block text-xs font-semibold text-slate-300">Contraseña</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3.5 flex items-center text-slate-500 text-xs">
<i class="fa-solid fa-key"></i>
</span>
<input type="password" id="password" name="password" required placeholder="••••••••" class="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-4 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-brand-500 transition">
</div>
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-sm transition flex items-center justify-center gap-2 shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-building-shield"></i>
<span>Ingresar con WebControl</span>
</button>
</form>
<div class="border-t border-slate-800 pt-4 text-center">
<a href="/" class="text-xs text-slate-400 hover:text-white transition flex items-center justify-center gap-1.5">
<i class="fa-solid fa-arrow-left"></i>
<span>Continuar como Invitado (sin registro)</span>
</a>
</div>
</div>
</div>
{{end}}
+109
View File
@@ -0,0 +1,109 @@
{{template "base.html" .}}
{{define "title"}}Mis Archivos — app_compartir{{end}}
{{define "content"}}
<div class="max-w-5xl mx-auto space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-5">
<div>
<h1 class="text-2xl font-bold text-white">Mis Archivos Guardados</h1>
<p class="text-xs text-slate-400">
Archivos y notas asociados a tu cuenta. Se conservan de forma <span class="text-emerald-400 font-semibold">permanente</span> hasta que decidas eliminarlos.
</p>
</div>
<a href="/" class="px-4 py-2 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-xs transition flex items-center gap-2 shadow-lg shadow-brand-500/20 shrink-0">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Subir nuevo archivo</span>
</a>
</div>
{{if eq (len .Shares) 0}}
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center space-y-4">
<div class="w-16 h-16 mx-auto rounded-2xl bg-slate-800 text-slate-400 flex items-center justify-center text-2xl">
<i class="fa-regular fa-folder-open"></i>
</div>
<div class="space-y-1">
<h3 class="text-base font-semibold text-white">No tienes archivos guardados aún</h3>
<p class="text-xs text-slate-400">Sube fotos, videos, documentos o notas para acceder a ellos en cualquier momento.</p>
</div>
<a href="/" class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-brand-400 text-xs font-semibold border border-slate-700 transition">
<span>Comenzar a compartir</span>
</a>
</div>
{{else}}
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-slate-950/60 border-b border-slate-800 text-slate-400 uppercase font-semibold">
<tr>
<th class="py-3.5 px-4">Archivo / Título</th>
<th class="py-3.5 px-4">Tipo</th>
<th class="py-3.5 px-4">Tamaño</th>
<th class="py-3.5 px-4">Visitas</th>
<th class="py-3.5 px-4">Fecha</th>
<th class="py-3.5 px-4 text-right">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60 text-slate-200">
{{range .Shares}}
<tr class="hover:bg-slate-800/40 transition">
<td class="py-3.5 px-4 font-medium max-w-xs truncate">
<a href="/v/{{.ID}}" target="_blank" class="hover:text-brand-400 flex items-center gap-2 truncate" title="{{.Title}}">
{{if eq .Type "text"}}
<i class="fa-solid fa-code text-indigo-400 shrink-0"></i>
{{else}}
<i class="fa-solid fa-file text-slate-400 shrink-0"></i>
{{end}}
<span class="truncate">{{.Title}}</span>
</a>
</td>
<td class="py-3.5 px-4 text-slate-400">{{.MimeType}}</td>
<td class="py-3.5 px-4 font-mono text-slate-300">{{.FormattedSize}}</td>
<td class="py-3.5 px-4">{{.ViewsCount}}</td>
<td class="py-3.5 px-4 text-slate-400">{{.CreatedAt.Format "02/01/2006 15:04"}}</td>
<td class="py-3.5 px-4 text-right space-x-1">
<a href="/v/{{.ID}}" target="_blank" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Abrir visor">
<i class="fa-solid fa-eye text-xs"></i>
</a>
{{if ne .Type "text"}}
<a href="/d/{{.ID}}" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Descargar">
<i class="fa-solid fa-download text-xs"></i>
</a>
{{end}}
<button onclick="deleteShare('{{.ID}}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-rose-900/40 text-slate-400 hover:text-rose-400 transition" title="Eliminar definitivamente">
<i class="fa-solid fa-trash text-xs"></i>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
</div>
{{end}}
{{define "scripts"}}
<script>
async function deleteShare(id) {
if (!confirm('¿Estás seguro de que deseas eliminar este archivo? Esta acción no se puede deshacer.')) {
return;
}
try {
const resp = await fetch(`/api/shares/${id}`, {
method: 'DELETE'
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json();
alert('Error al eliminar: ' + (data.error || 'Desconocido'));
}
} catch (err) {
alert('Error de red al intentar eliminar el archivo.');
}
}
</script>
{{end}}
+228
View File
@@ -0,0 +1,228 @@
{{template "base.html" .}}
{{define "title"}}{{.Share.Title}} — Visor de app_compartir{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header Card -->
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-1.5 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-xl sm:text-2xl font-bold text-white truncate max-w-lg" title="{{.Share.Title}}">
{{.Share.Title}}
</h1>
{{if .Share.IsGuest}}
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 border border-amber-500/20 text-amber-400">
<i class="fa-regular fa-clock mr-1"></i> Caduca en 24h
</span>
{{else}}
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
<i class="fa-solid fa-shield mr-1"></i> Permanente
</span>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
<span><i class="fa-solid fa-user text-slate-500 mr-1"></i> {{.Share.UserDisplay}}</span>
{{if ne .Share.Type "text"}}
<span><i class="fa-solid fa-hard-drive text-slate-500 mr-1"></i> {{.Share.FormattedSize}}</span>
{{end}}
<span><i class="fa-solid fa-eye text-slate-500 mr-1"></i> {{.Share.ViewsCount}} visitas</span>
<span><i class="fa-solid fa-calendar-day text-slate-500 mr-1"></i> {{.FormattedDate}}</span>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 shrink-0">
{{if ne .Share.Type "text"}}
<a href="/d/{{.Share.ID}}" class="px-4 py-2 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-sm transition flex items-center gap-1.5 shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-download"></i>
<span>Descargar</span>
</a>
<a href="/raw/{{.Share.ID}}" target="_blank" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Ver archivo original / raw">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
</a>
{{else}}
<button onclick="copyTextContent()" id="copyTextBtn" class="px-4 py-2 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-sm transition flex items-center gap-1.5 shadow-lg shadow-brand-500/20">
<i class="fa-regular fa-copy"></i>
<span id="copyTextBtnLabel">Copiar texto</span>
</button>
{{end}}
<button onclick="toggleQRModal()" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Ver código QR para móvil">
<i class="fa-solid fa-qrcode"></i>
</button>
<button onclick="copyCurrentURL()" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Copiar enlace">
<i class="fa-solid fa-link"></i>
</button>
</div>
</div>
<!-- Viewer Body -->
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-4 sm:p-6 shadow-2xl overflow-hidden">
{{if .IsImage}}
<!-- Image Viewer -->
<div class="flex flex-col items-center justify-center space-y-3">
<div class="relative group max-h-[80vh] flex items-center justify-center rounded-2xl overflow-hidden bg-slate-950/60 p-2 border border-slate-800/80">
<img id="viewerImage" src="/raw/{{.Share.ID}}" alt="{{.Share.Title}}" class="max-h-[75vh] w-auto max-w-full object-contain rounded-xl shadow-lg transition-transform duration-200">
</div>
<div class="flex items-center gap-2">
<button onclick="toggleImageZoom()" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 border border-slate-700 transition flex items-center gap-1.5">
<i class="fa-solid fa-magnifying-glass-plus text-brand-400"></i>
<span id="zoomBtnLabel">Zoom 100%</span>
</button>
<a href="/raw/{{.Share.ID}}" target="_blank" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 border border-slate-700 transition flex items-center gap-1.5">
<i class="fa-solid fa-expand text-brand-400"></i>
<span>Pantalla Completa</span>
</a>
</div>
</div>
{{else if .IsVideo}}
<!-- Video Viewer -->
<div class="rounded-2xl overflow-hidden bg-black border border-slate-800 flex items-center justify-center">
<video controls autoplay playsinline class="w-full max-h-[75vh] rounded-2xl">
<source src="/raw/{{.Share.ID}}" type="{{.Share.MimeType}}">
Tu navegador no soporta la reproducción de video HTML5.
</video>
</div>
{{else if .IsAudio}}
<!-- Audio Viewer -->
<div class="p-8 sm:p-12 flex flex-col items-center justify-center space-y-6 bg-slate-950/60 rounded-2xl border border-slate-800/80">
<div class="w-20 h-20 rounded-3xl bg-brand-500/10 border border-brand-500/20 text-brand-400 flex items-center justify-center text-3xl shadow-xl shadow-brand-500/10 animate-pulse">
<i class="fa-solid fa-music"></i>
</div>
<div class="text-center space-y-1">
<h3 class="text-lg font-bold text-white">{{.Share.Title}}</h3>
<p class="text-xs text-slate-400">{{.Share.FormattedSize}} • {{.Share.MimeType}}</p>
</div>
<div class="w-full max-w-md">
<audio controls autoplay class="w-full">
<source src="/raw/{{.Share.ID}}" type="{{.Share.MimeType}}">
Tu navegador no soporta audio HTML5.
</audio>
</div>
</div>
{{else if .IsPDF}}
<!-- PDF Viewer -->
<div class="space-y-3">
<div class="w-full h-[80vh] rounded-2xl overflow-hidden border border-slate-800 bg-slate-950">
<iframe src="/raw/{{.Share.ID}}" class="w-full h-full border-none">
Tu navegador no soporta iframe para PDFs. <a href="/raw/{{.Share.ID}}" class="text-brand-400 underline">Haz clic para descargarlo</a>.
</iframe>
</div>
</div>
{{else if or (eq .Share.Type "text") .IsText}}
<!-- Text / Code Viewer -->
<div class="relative space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400 px-1">
<span><i class="fa-solid fa-file-lines mr-1 text-slate-500"></i> Formato de texto</span>
<span>{{len .Share.TextContent}} caracteres</span>
</div>
<div class="relative group">
<pre id="rawTextContent" class="p-5 rounded-2xl bg-slate-950 border border-slate-800 font-mono text-xs sm:text-sm text-slate-100 overflow-x-auto whitespace-pre-wrap leading-relaxed custom-scrollbar selection:bg-brand-500 selection:text-black">{{.Share.TextContent}}</pre>
</div>
</div>
{{else}}
<!-- Generic File Download Card -->
<div class="p-10 sm:p-14 flex flex-col items-center justify-center space-y-6 text-center bg-slate-950/60 rounded-2xl border border-slate-800/80">
<div class="w-20 h-20 rounded-3xl bg-slate-800 border border-slate-700 text-slate-300 flex items-center justify-center text-3xl shadow-xl">
<i class="fa-solid fa-file-zipper"></i>
</div>
<div class="space-y-1">
<h3 class="text-lg font-bold text-white max-w-md break-all">{{.Share.Title}}</h3>
<p class="text-xs text-slate-400">{{.Share.FormattedSize}} • {{.Share.MimeType}}</p>
</div>
<a href="/d/{{.Share.ID}}" class="px-6 py-3 rounded-xl bg-brand-600 hover:bg-brand-500 text-slate-950 font-bold text-sm transition flex items-center gap-2 shadow-lg shadow-brand-500/20">
<i class="fa-solid fa-download"></i>
<span>Descargar archivo ahora</span>
</a>
</div>
{{end}}
</div>
<!-- Quick Share QR Drawer for Mobile -->
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-5 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-3 text-center sm:text-left">
<div class="w-10 h-10 rounded-xl bg-brand-500/10 border border-brand-500/20 text-brand-400 flex items-center justify-center text-lg shrink-0">
<i class="fa-solid fa-mobile-screen"></i>
</div>
<div>
<p class="text-sm font-semibold text-white">¿Quieres abrir esto en tu teléfono?</p>
<p class="text-xs text-slate-400">Escanea el código QR sin enviar mensajes por WhatsApp</p>
</div>
</div>
<div class="flex items-center gap-3">
<img src="/qr/{{.Share.ID}}" alt="QR Code" class="w-16 h-16 rounded-lg bg-white p-1 shadow">
<button onclick="toggleQRModal()" class="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 border border-slate-700 transition">
Ver QR en grande
</button>
</div>
</div>
</div>
<!-- QR Modal -->
<div id="qrModal" class="hidden fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-6 max-w-sm w-full space-y-4 text-center shadow-2xl animate-fade-in">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white">Escanea con tu teléfono</h3>
<button onclick="toggleQRModal()" class="text-slate-400 hover:text-white p-1"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="p-3 bg-white rounded-2xl shadow-inner inline-block">
<img src="/qr/{{.Share.ID}}" alt="Código QR grande" class="w-56 h-56 object-contain">
</div>
<p class="text-xs text-slate-400">Apunta la cámara de tu móvil para abrir este visor directamente.</p>
<button onclick="toggleQRModal()" class="w-full py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-sm font-semibold text-white transition">
Cerrar
</button>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
let isZoomed = false;
function toggleImageZoom() {
const img = document.getElementById('viewerImage');
const label = document.getElementById('zoomBtnLabel');
if (!img) return;
isZoomed = !isZoomed;
if (isZoomed) {
img.classList.remove('max-h-[75vh]');
img.classList.add('max-h-none', 'scale-110');
label.innerText = 'Restablecer';
} else {
img.classList.add('max-h-[75vh]');
img.classList.remove('max-h-none', 'scale-110');
label.innerText = 'Zoom 100%';
}
}
function toggleQRModal() {
const modal = document.getElementById('qrModal');
modal.classList.toggle('hidden');
}
function copyCurrentURL() {
navigator.clipboard.writeText(window.location.href).then(() => {
alert('¡Enlace copiado al portapapeles!');
});
}
function copyTextContent() {
const text = document.getElementById('rawTextContent').innerText;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copyTextBtnLabel');
btn.innerText = '¡Copiado!';
setTimeout(() => {
btn.innerText = 'Copiar texto';
}, 2000);
});
}
</script>
{{end}}