feat: implementar webapp app_compartir con visor universal, auth y purga 24h para guest
This commit is contained in:
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
| # | Objetivo | Origen | Prioridad |
|
| # | 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 |
|
| Fecha | Objetivo | Notas |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-12 | Estructura base de agentes y documentación inicializada | Plantilla Telcotronics |
|
| 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 |
|
||||||
|
|||||||
@@ -9,27 +9,71 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
app_compartir/
|
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
|
├── README.md
|
||||||
├── agents.md
|
└── agents.md # Punto de entrada del agente
|
||||||
└── .agente/
|
|
||||||
├── infraestructura.md
|
|
||||||
├── proyecto_estructura.md
|
|
||||||
├── proyecto_memoria.md
|
|
||||||
├── proyecto_errores.md
|
|
||||||
├── objetivos.md
|
|
||||||
└── ideas.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rutas (HTTP / CLI / eventos)
|
## Rutas HTTP / Endpoints
|
||||||
|
|
||||||
| Método | Ruta | Handler | Descripción |
|
| 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
|
## 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.
|
||||||
|
|||||||
@@ -10,23 +10,30 @@
|
|||||||
| Fecha | Decisión | Razonamiento |
|
| 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 | 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
|
## 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
|
## Pendientes
|
||||||
|
|
||||||
- [ ] Definir alcance y objetivos del proyecto `app_compartir`
|
- [ ] Implementar base de datos SQLite y esquema de tablas (`shares`, `users`, `sessions`)
|
||||||
- [ ] Inicializar módulo Go (`go mod init app_compartir`)
|
- [ ] Implementar endpoints HTTP de subida (`/api/upload`, `/api/share-text`)
|
||||||
- [ ] Definir arquitectura base
|
- [ ] 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
|
## 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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
# app_compartir
|
# 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+
|
- Go 1.22+
|
||||||
|
|
||||||
## Instalación
|
## Instalación y Arranque
|
||||||
|
|
||||||
```bash
|
```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 |
|
| 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 ./...
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# agents.md — Punto de entrada del proyecto
|
# agents.md — Punto de entrada del proyecto: app_compartir
|
||||||
|
|
||||||
> **Reglas:**
|
> **Reglas:**
|
||||||
> - Este archivo es el eslabón más importante. Leerlo siempre antes de cualquier tarea.
|
> - Este archivo es el eslabón más importante. Leerlo siempre antes de cualquier tarea.
|
||||||
@@ -11,16 +11,20 @@
|
|||||||
|
|
||||||
## Antecedentes
|
## 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
|
## 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]
|
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. [COMPLETAR — responsabilidad 2]
|
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 |
|
| Componente | Tecnología |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Lenguaje | Go |
|
| Lenguaje | Go 1.22+ / 1.27 |
|
||||||
| Framework | [COMPLETAR] |
|
| Router / HTTP | `net/http` estándar con enrutamiento nativo |
|
||||||
| Base de datos | [COMPLETAR] |
|
| Base de datos | SQLite (`modernc.org/sqlite` o `mattn/go-sqlite3`) |
|
||||||
| [Otros] | [COMPLETAR] |
|
| 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
|
## Módulos del sistema
|
||||||
|
|
||||||
### 1. [Nombre del módulo]
|
### 1. Motor de Almacenamiento y Uploads (`storage`)
|
||||||
**Estado: ⏳ Pendiente**
|
**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 |
|
| Parámetro | Valor |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Dominio público | [COMPLETAR] |
|
| Dominio público | Por definir (ej: `compartir.sigmac.app` / `compartir.telcotronics.net`) |
|
||||||
| Puerto interno | [COMPLETAR] |
|
| Puerto interno | `8085` (o configurable vía `PORT`) |
|
||||||
| Servicio systemd | [COMPLETAR] |
|
| Servicio systemd | `app-compartir.service` |
|
||||||
| BD principal | [COMPLETAR] |
|
| BD principal | SQLite local (`data/compartir.db`) |
|
||||||
| MCP ecosistema | `mcp.telcotronics.net` 🔧 en mantenimiento |
|
| 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`)
|
## Variables de entorno (`.env`)
|
||||||
|
|
||||||
| Variable | Descripción |
|
| Variable | Descripción | Default |
|
||||||
|---|---|
|
|
||||||
| [COMPLETAR] | [COMPLETAR] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migraciones
|
|
||||||
|
|
||||||
Runner: `[COMPLETAR]`
|
|
||||||
|
|
||||||
| Archivo | Tabla principal | Estado |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| — | — | — |
|
| `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
|
## Para arrancar en local
|
||||||
|
|
||||||
```bash
|
```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/
|
app_compartir/
|
||||||
├── README.md
|
├── README.md
|
||||||
├── agents.md # ← LEER PRIMERO
|
├── agents.md # ← LEER PRIMERO
|
||||||
|
├── go.mod
|
||||||
|
├── main.go
|
||||||
└── .agente/
|
└── .agente/
|
||||||
├── infraestructura.md # Ecosistema — espejo del global
|
├── infraestructura.md # Ecosistema — espejo del global
|
||||||
├── proyecto_estructura.md # Árbol de archivos y rutas
|
├── proyecto_estructura.md # Árbol de archivos y rutas
|
||||||
@@ -127,4 +130,8 @@ app_compartir/
|
|||||||
|
|
||||||
| Módulo | Estado |
|
| 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user