feat: implementar webapp app_compartir con visor universal, auth y purga 24h para guest
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user