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) getBaseURL(r *http.Request) string { proto := "http" if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || strings.EqualFold(r.Header.Get("X-Forwarded-Ssl"), "on") || r.Header.Get("X-Forwarded-Port") == "443" { proto = "https" } host := r.Host if host == "" { host = r.Header.Get("X-Forwarded-Host") } if host != "" { if strings.HasPrefix(h.cfg.BaseURL, "https://") && strings.Contains(h.cfg.BaseURL, host) { proto = "https" } return fmt.Sprintf("%s://%s", proto, host) } return h.cfg.BaseURL } 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 } baseURL := h.getBaseURL(r) 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", baseURL, share.ID), "download_url": fmt.Sprintf("%s/d/%s", 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 } baseURL := h.getBaseURL(r) 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", baseURL, share.ID), "download_url": fmt.Sprintf("%s/d/%s", 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 } baseURL := h.getBaseURL(r) targetURL := fmt.Sprintf("%s/v/%s", 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}) }