Files
app_compartir/internal/handlers/handlers.go
T

620 lines
16 KiB
Go

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})
}