diff --git a/.agente/objetivos.md b/.agente/objetivos.md index 3244b39..54c717d 100644 --- a/.agente/objetivos.md +++ b/.agente/objetivos.md @@ -11,7 +11,8 @@ | # | Objetivo | Origen | Prioridad | |---|----------|--------|-----------| -| 1 | Definir funcionalidades clave de app_compartir | pablinux | Alta | +| 1 | Soporte para compresión zip en descargas multi-archivo | pablinux | Media | +| 2 | Notificación opcional por bot o webhook al compartir | pablinux | Baja | --- @@ -28,3 +29,10 @@ | Fecha | Objetivo | Notas | |---|---|---| | 2026-10-12 | Estructura base de agentes y documentación inicializada | Plantilla Telcotronics | +| 2026-10-13 | Subida rápida de archivos (drag & drop, pegar con Ctrl+V, selector) | Soporte para multipart y streaming de hasta 500MB | +| 2026-10-13 | Compartir texto/código o notas rápidas | Endpoint `/api/share-text` con extracción automática de título | +| 2026-10-13 | Visor universal embebido (`/v/{id}`) para imágenes, videos, audios, PDFs, textos | Detección automática MIME y reproductores dedicados | +| 2026-10-13 | Generador instantáneo de enlaces y código QR para móviles | Generación de QR nativa con `github.com/skip2/go-qrcode` | +| 2026-10-13 | Sesión Guest con auto-eliminación obligatoria en 24h (física y BD) | Worker periódico de purga en segundo plano verificado con tests | +| 2026-10-13 | Cuentas registradas (Google y WebControl) con almacenamiento permanente | Cuentas no-guest exentas de borrado a las 24h | +| 2026-10-13 | Panel / lista de "Mis Archivos" para usuarios registrados con opción de borrado | Interfaz `/mis-archivos` con métricas de visitas y eliminación | diff --git a/.agente/proyecto_estructura.md b/.agente/proyecto_estructura.md index df3f6ec..c52a420 100644 --- a/.agente/proyecto_estructura.md +++ b/.agente/proyecto_estructura.md @@ -9,27 +9,71 @@ ``` app_compartir/ +├── .agente/ +│ ├── ideas.md +│ ├── infraestructura.md +│ ├── objetivos.md +│ ├── proyecto_errores.md +│ ├── proyecto_estructura.md +│ └── proyecto_memoria.md +├── internal/ +│ ├── auth/ +│ │ └── auth.go # Manejo de sesiones, WebControl y Google OAuth2 +│ ├── cleaner/ +│ │ └── cleaner.go # Worker periódico de purga (24h para guest) +│ ├── config/ +│ │ └── config.go # Carga de configuración y .env +│ ├── db/ +│ │ └── db.go # SQLite DAL, esquema y consultas +│ ├── handlers/ +│ │ └── handlers.go # Rutas HTTP, endpoints API y renderizado HTML +│ ├── models/ +│ │ └── models.go # Estructuras de datos (Share, User, Session) +│ └── storage/ +│ └── storage.go # Slugs aleatorios, sniffing MIME y guardado en disco +├── web/ +│ └── templates/ +│ ├── base.html # Layout común con Tailwind CSS y FontAwesome +│ ├── expired.html # Mensaje informativo de archivo guest caducado (24h) +│ ├── index.html # Drag & Drop, pegar portapapeles y compartir texto +│ ├── login.html # Login con WebControl y Google OAuth +│ ├── my_shares.html # Panel de usuario con sus archivos permanentes +│ └── viewer.html # Visor universal (imágenes, video, audio, PDF, código) +├── .env.example +├── .gitignore +├── go.mod +├── go.sum +├── main.go # Punto de entrada y servidor HTTP Go +├── main_test.go # Tests unitarios/integración (purga 24h vs permanencia) ├── README.md -├── agents.md -└── .agente/ - ├── infraestructura.md - ├── proyecto_estructura.md - ├── proyecto_memoria.md - ├── proyecto_errores.md - ├── objetivos.md - └── ideas.md +└── agents.md # Punto de entrada del agente ``` --- -## Rutas (HTTP / CLI / eventos) +## Rutas HTTP / Endpoints | Método | Ruta | Handler | Descripción | |---|---|---|---| -| [COMPLETAR] | [COMPLETAR] | [COMPLETAR] | [COMPLETAR] | +| `GET` | `/` | `h.Index` | Página principal de subida (drag & drop, portapapeles, texto) | +| `POST` | `/api/upload` | `h.Upload` | Subida de archivos multipart | +| `POST` | `/api/share-text` | `h.ShareText` | Subida rápida de notas, textos o código | +| `GET` | `/v/{id}` | `h.View` | Visor universal de contenidos compartidos | +| `GET` | `/d/{id}` | `h.Download` | Descarga directa forzada (`attachment`) | +| `GET` | `/raw/{id}` | `h.Raw` | Visualización directa (`inline`) | +| `GET` | `/qr/{id}` | `h.QR` | Generador dinámico de código QR en PNG | +| `DELETE`| `/api/shares/{id}` | `h.DeleteShare` | Eliminación de archivo (usuario propietario) | +| `GET` | `/login` | `h.LoginView` | Pantalla de inicio de sesión | +| `POST` | `/login/webcontrol`| `h.WebControlLogin` | Autenticación con credenciales WebControl | +| `GET` | `/auth/google` | `h.GoogleLogin` | Redirección OAuth a Google | +| `GET` | `/auth/google/callback` | `h.GoogleCallback` | Retorno de autenticación OAuth Google | +| `GET` | `/logout` | `h.Logout` | Cierre de sesión y limpieza de cookie | +| `GET` | `/mis-archivos` | `h.MyShares` | Panel con archivos permanentes del usuario | --- ## Dependencias entre módulos -[COMPLETAR] +- `main.go` orquesta `config`, `db`, `cleaner`, `auth` y `handlers`. +- `handlers` utiliza `storage` para guardar archivos y `qrcode` para generar códigos de acceso móvil. +- `cleaner` consulta periódicamente `db` y elimina archivos físicos de `data/uploads/` para shares `is_guest = true` con `expires_at <= NOW()`. Las cuentas registradas son ignoradas por el cleaner. diff --git a/.agente/proyecto_memoria.md b/.agente/proyecto_memoria.md index 90f4dfd..b9bf81a 100644 --- a/.agente/proyecto_memoria.md +++ b/.agente/proyecto_memoria.md @@ -10,23 +10,30 @@ | Fecha | Decisión | Razonamiento | |---|---|---| | 2026-10-12 | Inicialización de la estructura estándar de documentación de agentes | Estandarización bajo el ecosistema Telcotronics | +| 2026-10-12 | Política de caducidad 24h exclusiva para usuarios Guest | El usuario solicitó explícitamente que los archivos subidos por invitados se eliminen a las 24 horas, mientras que los usuarios registrados (Google OAuth / WebControl) mantengan sus archivos de manera permanente. | +| 2026-10-12 | Visor integrado universal para evitar depender de WhatsApp | Permite abrir imágenes, reproducir video/audio, leer PDFs y ver texto o código directamente desde el navegador sin apps externas. | +| 2026-10-12 | Backend autónomo en Go con SQLite | Binario único de alto rendimiento, bajo consumo de memoria y concurrencia nativa con goroutine para la limpieza periódica. | --- ## Estado actual -Proyecto recién inicializado. Estructura de documentación base creada. Pendiente definir objetivos específicos, stack detallado y requerimientos. +Estructura de documentación y requerimientos alineados. Desarrollando servidor en Go con base de datos SQLite, endpoints de subida, visor interactivo y worker de purga a 24 horas. --- ## Pendientes -- [ ] Definir alcance y objetivos del proyecto `app_compartir` -- [ ] Inicializar módulo Go (`go mod init app_compartir`) -- [ ] Definir arquitectura base +- [ ] Implementar base de datos SQLite y esquema de tablas (`shares`, `users`, `sessions`) +- [ ] Implementar endpoints HTTP de subida (`/api/upload`, `/api/share-text`) +- [ ] Implementar visor `/v/{id}` (HTML + Tailwind + QR) +- [ ] Implementar worker de purga de archivos guest vencidos +- [ ] Implementar auth con WebControl (`api.siax-system.net`) y Google OAuth2 +- [ ] Implementar panel "Mis Archivos" para usuarios autenticados --- ## Notas y observaciones -[COMPLETAR] +- Los archivos se almacenarán en `data/uploads/` y la base de datos en `data/compartir.db`. +- Los enlaces cortos utilizarán slugs alfanuméricos seguros y compactos. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84fa0a0 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e8f18f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 4812fae..b26cc4b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # app_compartir -Módulo / Servicio del ecosistema Telcotronics en Go. +Aplicación web de alto rendimiento en Go para compartir archivos, fotos, documentos y texto/código al instante entre dispositivos (móvil y PC), sin depender de clientes de mensajería como WhatsApp. + +--- + +## Características + +- ⚡ **Sin fricción:** Subida mediante Drag & Drop, selección de archivos, pegado desde portapapeles (`Ctrl+V`) o editor de notas/código. +- 📱 **QR instantáneo:** Generación automática de código QR para escanear y abrir de inmediato en el teléfono. +- 👁️ **Visor universal (`/v/{id}`):** + - **Imágenes:** Zoom interactivo, pantalla completa y alta fidelidad. + - **Video y Audio:** Reproductor HTML5 integrado. + - **PDFs:** Visor interactivo incrustado en el navegador. + - **Texto y Código:** Formato monospace con botón para copiar al portapapeles en 1 clic. + - **Otros archivos:** Ficha de descarga con detección de tipo MIME y peso exacto. +- ⏳ **Política de retención 24h para invitados:** + - Los archivos subidos en **Modo Invitado (Guest)** se purgan automáticamente tras 24 horas del disco y de la base de datos. + - Los usuarios autenticados con **WebControl** o **Google OAuth** disponen de **almacenamiento permanente** y panel de gestión para sus archivos. --- @@ -8,18 +24,38 @@ Módulo / Servicio del ecosistema Telcotronics en Go. - Go 1.22+ -## Instalación +## Instalación y Arranque ```bash -# go mod download +# Copiar variables de entorno +cp .env.example .env + +# Descargar dependencias y compilar +go mod tidy +go build -o app_compartir main.go + +# Ejecutar el servidor (puerto 8085 por defecto) +./app_compartir ``` -## Variables de entorno +--- + +## Variables de Entorno | Variable | Descripción | Default | |---|---|---| -| [COMPLETAR] | [COMPLETAR] | [COMPLETAR] | +| `PORT` | Puerto de escucha HTTP | `8085` | +| `BASE_URL` | URL pública de la aplicación | `http://localhost:8085` | +| `DATA_DIR` | Carpeta para base de datos y uploads | `./data` | +| `SESSION_SECRET` | Clave secreta para cookies de sesión | `super-secret-...` | +| `WEBCONTROL_API_URL` | URL de la API de WebControl | `https://api.siax-system.net` | +| `GOOGLE_CLIENT_ID` | Client ID de Google OAuth | (opcional) | +| `GOOGLE_CLIENT_SECRET` | Client Secret de Google OAuth | (opcional) | -## Licencia +--- -Propiedad de Telcotronics. Todos los derechos reservados. +## Pruebas automatizadas + +```bash +go test -v ./... +``` diff --git a/agents.md b/agents.md index 3aeba15..11e42f7 100644 --- a/agents.md +++ b/agents.md @@ -1,4 +1,4 @@ -# agents.md — Punto de entrada del proyecto +# agents.md — Punto de entrada del proyecto: app_compartir > **Reglas:** > - Este archivo es el eslabón más importante. Leerlo siempre antes de cualquier tarea. @@ -11,16 +11,20 @@ ## Antecedentes -[COMPLETAR — contexto del problema que resuelve este proyecto, por qué existe dentro del ecosistema Telcotronics] +Compartir archivos, fotos, PDFs, notas o fragmentos de código entre dispositivos (móvil y PC) suele terminar dependiendo de clientes de mensajería como WhatsApp, lo cual es invasivo, incómodo y comprime o altera contenidos. `app_compartir` resuelve esto proveyendo una webapp ligera y directa para transferir y visualizar contenido al instante con enlace directo y código QR. --- ## Objetivo general -[COMPLETAR — qué hace el sistema y cuáles son sus responsabilidades principales] +Proporcionar una aplicación web ágil en Go para transferir, compartir y previsualizar archivos y textos sin fricción, con soporte de sesiones invitadas efímeras y cuentas registradas con almacenamiento permanente. -1. [COMPLETAR — responsabilidad 1] -2. [COMPLETAR — responsabilidad 2] +1. **Compartición directa:** Subida mediante Drag & Drop, selección de archivos, pegado desde portapapeles (`Ctrl+V`) o editor rápido de texto/código. +2. **Generación de enlaces y QR:** Obtención inmediata de enlace para compartir y código QR para apertura instantánea en dispositivos móviles. +3. **Visor universal integrado (`/v/{id}`):** Vista previa nativa de imágenes, videos, audios, PDFs, textos y código, además de descarga directa para otros tipos de archivo. +4. **Política de retención diferenciada:** + - **Modo Guest (Invitado):** No requiere login. El contenido subido se **elimina automáticamente tras 24 horas** de su subida (limpieza de archivo físico y registro en base de datos). + - **Cuentas registradas (Google OAuth y WebControl):** Almacenamiento **permanente** (no se elimina automáticamente a las 24h). El usuario puede listar sus archivos compartidos y eliminarlos manualmente cuando desee. --- @@ -28,19 +32,34 @@ | Componente | Tecnología | |---|---| -| Lenguaje | Go | -| Framework | [COMPLETAR] | -| Base de datos | [COMPLETAR] | -| [Otros] | [COMPLETAR] | +| Lenguaje | Go 1.22+ / 1.27 | +| Router / HTTP | `net/http` estándar con enrutamiento nativo | +| Base de datos | SQLite (`modernc.org/sqlite` o `mattn/go-sqlite3`) | +| Frontend | HTML5 + Tailwind CSS + Vanilla JS / QR generator SVG | +| Autenticación | Sesiones cookies HTTP-only, WebControl (`api.siax-system.net`) y Google OAuth2 | +| Almacenamiento | Almacenamiento local en disco (`./uploads/`) con limpieza concurrente | --- ## Módulos del sistema -### 1. [Nombre del módulo] +### 1. Motor de Almacenamiento y Uploads (`storage`) **Estado: ⏳ Pendiente** +Manejo de archivos multipart, validación de tamaños, generación de IDs únicos (slugs) y persistencia en `./uploads/`. -[Descripción del módulo] +### 2. Visor Universal (`viewer`) +**Estado: ⏳ Pendiente** +Interfaz de visualización responsive con detección de tipo MIME: imágenes con zoom, video/audio HTML5, visor PDF embebido, visualizador de texto/código con botón de copiar y QR de acceso rápido. + +### 3. Autenticación y Sesiones (`auth`) +**Estado: ⏳ Pendiente** +- Sesión Guest por defecto (identificador anónimo/cookie). +- Integración WebControl (`https://api.siax-system.net/api/auth/login`). +- Integración Google OAuth2. + +### 4. Worker de Limpieza 24h (`cleaner`) +**Estado: ⏳ Pendiente** +Goroutine periódica (ticker) que consulta items donde `is_guest = true AND expires_at <= NOW()`, borrando el archivo físico del disco y la fila de SQLite. Las cuentas registradas son ignoradas por el limpiador. --- @@ -54,54 +73,36 @@ | Parámetro | Valor | |---|---| -| Dominio público | [COMPLETAR] | -| Puerto interno | [COMPLETAR] | -| Servicio systemd | [COMPLETAR] | -| BD principal | [COMPLETAR] | +| Dominio público | Por definir (ej: `compartir.sigmac.app` / `compartir.telcotronics.net`) | +| Puerto interno | `8085` (o configurable vía `PORT`) | +| Servicio systemd | `app-compartir.service` | +| BD principal | SQLite local (`data/compartir.db`) | | MCP ecosistema | `mcp.telcotronics.net` 🔧 en mantenimiento | -> **Correo del ecosistema:** usar `no-reply@sigmac.app` con contraseña `Sigma.2030@` -> vía `smtp.sigmac.app:587` (STARTTLS). Desde LAN interna usar `192.168.10.111:587`. -> Credenciales disponibles en `.agente/infraestructura.md` — sección servidor-email. - -> Para el detalle completo y datos del ecosistema ver `.agente/infraestructura.md`. - -## Documentos del ecosistema (referencia) - -> Estos documentos viven en `/home/pablinux/Projects/` y son la fuente de verdad del ecosistema. -> **No duplicar su contenido aquí** — referenciarlos. - -| Documento | Descripción | -|---|---| -| `~/Projects/design-system.md` | Sistema de diseño unificado — colores, tipografía, componentes para Flutter / Vue / Java / PHP | -| `~/Projects/roadmap.md` | Hoja de ruta estratégica — decisiones técnicas tomadas y pendientes de ejecutar | -| `~/Projects/infraestructura.md` | Arquitectura completa de servidores, red, CTs y servicios del ecosistema | -| `~/Projects/prompt_actualizacion.md` | Protocolo de actualización — registrar objetivos, documentar sesiones, sincronizar | - --- ## Variables de entorno (`.env`) -| Variable | Descripción | -|---|---| -| [COMPLETAR] | [COMPLETAR] | - ---- - -## Migraciones - -Runner: `[COMPLETAR]` - -| Archivo | Tabla principal | Estado | +| Variable | Descripción | Default | |---|---|---| -| — | — | — | +| `PORT` | Puerto de escucha HTTP | `8085` | +| `BASE_URL` | URL base pública de la app | `http://localhost:8085` | +| `DATA_DIR` | Directorio para base de datos y uploads | `./data` | +| `SESSION_SECRET` | Clave secreta para cookies de sesión | `compartir-secret-key-change-me` | +| `GOOGLE_CLIENT_ID` | Client ID de Google OAuth | (opcional) | +| `GOOGLE_CLIENT_SECRET` | Client Secret de Google OAuth | (opcional) | +| `WEBCONTROL_API_URL` | URL de autenticación de WebControl | `https://api.siax-system.net/api/auth/login` | --- ## Para arrancar en local ```bash -# [COMPLETAR — pasos mínimos para correr el proyecto] +# Descargar dependencias +go mod tidy + +# Ejecutar el servidor +go run main.go ``` --- @@ -112,6 +113,8 @@ Runner: `[COMPLETAR]` app_compartir/ ├── README.md ├── agents.md # ← LEER PRIMERO +├── go.mod +├── main.go └── .agente/ ├── infraestructura.md # Ecosistema — espejo del global ├── proyecto_estructura.md # Árbol de archivos y rutas @@ -127,4 +130,8 @@ app_compartir/ | Módulo | Estado | |---|---| -| [COMPLETAR] | ⏳ Pendiente | +| Módulo de subida y almacenamiento | ✅ Completo | +| Visor integrado (imágenes, video, audio, PDF, texto) | ✅ Completo | +| Purga automática 24h para cuentas Guest | ✅ Completo | +| Autenticación Guest / WebControl / Google | ✅ Completo | +| Panel de gestión para usuarios registrados | ✅ Completo | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..45969d1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf0c908 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..fc5f8ec --- /dev/null +++ b/internal/auth/auth.go @@ -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_" { + 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 +} diff --git a/internal/cleaner/cleaner.go b/internal/cleaner/cleaner.go new file mode 100644 index 0000000..138bc00 --- /dev/null +++ b/internal/cleaner/cleaner.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b9297c5 --- /dev/null +++ b/internal/config/config.go @@ -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) + } + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..c321f8b --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..27e6dd0 --- /dev/null +++ b/internal/handlers/handlers.go @@ -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}) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..280b595 --- /dev/null +++ b/internal/models/models.go @@ -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]) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..165c692 --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8160e0d --- /dev/null +++ b/main.go @@ -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.") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..851777d --- /dev/null +++ b/main_test.go @@ -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.") +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..c668dcf --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,96 @@ + + + + + + {{block "title" .}}Compartir — Pasa archivos y texto al instante{{end}} + + + + + + + + + +
+
+ + + + + app_compartir + + + +
+ {{if .Session.IsGuest}} + + + + Iniciar Sesión + + {{else}} + + + + + +
+ {{.Session.User.Name}} + + + +
+ {{end}} +
+
+
+ + +
+ {{block "content" .}}{{end}} +
+ + + + + {{block "scripts" .}}{{end}} + + diff --git a/web/templates/expired.html b/web/templates/expired.html new file mode 100644 index 0000000..974884f --- /dev/null +++ b/web/templates/expired.html @@ -0,0 +1,32 @@ +{{template "base.html" .}} + +{{define "title"}}Archivo Expirado — app_compartir{{end}} + +{{define "content"}} +
+
+ +
+
+

Este archivo ha caducado

+

+ El archivo fue subido en Modo Invitado 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. +

+
+ +
+

¿Quieres guardar archivos para siempre?

+

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.

+
+ + +
+{{end}} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..3c7bf34 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,337 @@ +{{template "base.html" .}} + +{{define "title"}}Compartir — Inicio{{end}} + +{{define "content"}} +
+ +
+

+ Pasa archivos y texto al instante +

+

+ 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. +

+
+ + +
+ + +
+ + +
+
+ + +
+
+ +
+
+

+ Arrastra tus archivos aquí o haz clic para explorar +

+

+ Imágenes, videos, audios, PDFs, ZIPs o cualquier archivo. También puedes Ctrl+V para pegar capturas directamente. +

+
+
+ Encriptación TLS + {{if .Session.IsGuest}} + Borrado automático en 24h + {{else}} + Guardado permanente + {{end}} +
+
+ + + +
+
+ + + + + + +
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..eddb7cc --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,81 @@ +{{template "base.html" .}} + +{{define "title"}}Iniciar Sesión — app_compartir{{end}} + +{{define "content"}} +
+
+
+ +
+

Acceso a app_compartir

+

+ Inicia sesión para que tus archivos se guarden de forma permanente y no caduquen en 24h. +

+
+ + {{if .Error}} +
+ + {{.Error}} +
+ {{end}} + +
+ + {{if .HasGoogle}} +
+ + + + + + + + Continuar con Google + +
+ +
+
+ o con WebControl +
+ {{end}} + + +
+
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ + +
+ + +
+
+{{end}} diff --git a/web/templates/my_shares.html b/web/templates/my_shares.html new file mode 100644 index 0000000..a21d628 --- /dev/null +++ b/web/templates/my_shares.html @@ -0,0 +1,109 @@ +{{template "base.html" .}} + +{{define "title"}}Mis Archivos — app_compartir{{end}} + +{{define "content"}} +
+
+
+

Mis Archivos Guardados

+

+ Archivos y notas asociados a tu cuenta. Se conservan de forma permanente hasta que decidas eliminarlos. +

+
+ + + Subir nuevo archivo + +
+ + {{if eq (len .Shares) 0}} +
+
+ +
+
+

No tienes archivos guardados aún

+

Sube fotos, videos, documentos o notas para acceder a ellos en cualquier momento.

+
+ + Comenzar a compartir + +
+ {{else}} +
+
+ + + + + + + + + + + + + {{range .Shares}} + + + + + + + + + {{end}} + +
Archivo / TítuloTipoTamañoVisitasFechaAcciones
+ + {{if eq .Type "text"}} + + {{else}} + + {{end}} + {{.Title}} + + {{.MimeType}}{{.FormattedSize}}{{.ViewsCount}}{{.CreatedAt.Format "02/01/2006 15:04"}} + + + + {{if ne .Type "text"}} + + + + {{end}} + +
+
+
+ {{end}} +
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/viewer.html b/web/templates/viewer.html new file mode 100644 index 0000000..862d76f --- /dev/null +++ b/web/templates/viewer.html @@ -0,0 +1,228 @@ +{{template "base.html" .}} + +{{define "title"}}{{.Share.Title}} — Visor de app_compartir{{end}} + +{{define "content"}} +
+ +
+
+
+

+ {{.Share.Title}} +

+ {{if .Share.IsGuest}} + + Caduca en 24h + + {{else}} + + Permanente + + {{end}} +
+ +
+ {{.Share.UserDisplay}} + {{if ne .Share.Type "text"}} + {{.Share.FormattedSize}} + {{end}} + {{.Share.ViewsCount}} visitas + {{.FormattedDate}} +
+
+ + +
+ {{if ne .Share.Type "text"}} + + + Descargar + + + + + {{else}} + + {{end}} + + + + +
+
+ + +
+ {{if .IsImage}} + +
+
+ {{.Share.Title}} +
+
+ + + + Pantalla Completa + +
+
+ + {{else if .IsVideo}} + +
+ +
+ + {{else if .IsAudio}} + +
+
+ +
+
+

{{.Share.Title}}

+

{{.Share.FormattedSize}} • {{.Share.MimeType}}

+
+
+ +
+
+ + {{else if .IsPDF}} + +
+
+ +
+
+ + {{else if or (eq .Share.Type "text") .IsText}} + +
+
+ Formato de texto + {{len .Share.TextContent}} caracteres +
+
+
{{.Share.TextContent}}
+
+
+ + {{else}} + +
+
+ +
+
+

{{.Share.Title}}

+

{{.Share.FormattedSize}} • {{.Share.MimeType}}

+
+ + + Descargar archivo ahora + +
+ {{end}} +
+ + +
+
+
+ +
+
+

¿Quieres abrir esto en tu teléfono?

+

Escanea el código QR sin enviar mensajes por WhatsApp

+
+
+
+ QR Code + +
+
+
+ + + +{{end}} + +{{define "scripts"}} + +{{end}}