feat: implementar webapp app_compartir con visor universal, auth y purga 24h para guest

This commit is contained in:
2026-10-13 22:46:02 -05:00
parent 6ea00e4ec5
commit 8519590cc7
24 changed files with 2970 additions and 71 deletions
+300
View File
@@ -0,0 +1,300 @@
package auth
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"app_compartir/internal/config"
"app_compartir/internal/db"
"app_compartir/internal/models"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const (
CookieSessionName = "compartir_session"
)
type AuthService struct {
cfg *config.Config
db *db.Database
oauthConfig *oauth2.Config
}
func New(cfg *config.Config, database *db.Database) *AuthService {
var oauthConf *oauth2.Config
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
oauthConf = &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: cfg.BaseURL + "/auth/google/callback",
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
},
Endpoint: google.Endpoint,
}
}
return &AuthService{
cfg: cfg,
db: database,
oauthConfig: oauthConf,
}
}
func (a *AuthService) GenerateToken(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(bytes)
}
func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request) *models.Session {
cookie, err := r.Cookie(CookieSessionName)
if err == nil && cookie.Value != "" {
session, err := a.db.GetSession(cookie.Value)
if err == nil && session != nil {
return session
}
}
// Crear nueva sesión de invitado (Guest)
token := a.GenerateToken(32)
guestID := "guest_" + a.GenerateToken(8)
now := time.Now()
expiresAt := now.Add(30 * 24 * time.Hour) // La cookie de sesión dura 30 días, pero los archivos del guest expiran en 24h
session := &models.Session{
Token: token,
UserID: nil,
IsGuest: true,
GuestID: guestID,
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := a.db.CreateSession(session); err != nil {
return session
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: token,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return session
}
func (a *AuthService) AttachUserToSession(w http.ResponseWriter, r *http.Request, user *models.User) error {
token := a.GenerateToken(32)
now := time.Now()
expiresAt := now.Add(30 * 24 * time.Hour)
session := &models.Session{
Token: token,
UserID: &user.ID,
IsGuest: false,
GuestID: "",
CreatedAt: now,
ExpiresAt: expiresAt,
User: user,
}
if err := a.db.CreateSession(session); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: token,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (a *AuthService) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(CookieSessionName); err == nil && cookie.Value != "" {
_ = a.db.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: CookieSessionName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
})
}
// LoginWebControl realiza autenticación con WebControl de Telcotronics
func (a *AuthService) LoginWebControl(email, password string) (*models.User, error) {
baseURL := strings.TrimRight(a.cfg.WebControlURL, "/")
// 1. Consultar empresas disponibles para el usuario
empresasURL := baseURL + "/api/auth/empresas"
empReqBody, _ := json.Marshal(map[string]string{
"email": email,
"paswd": password,
})
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Post(empresasURL, "application/json", bytes.NewBuffer(empReqBody))
dba := ""
if err == nil && resp.StatusCode == http.StatusOK {
var empData struct {
Empresas []struct {
DBA string `json:"dba"`
Nombre string `json:"nombre"`
} `json:"empresas"`
}
if err := json.NewDecoder(resp.Body).Decode(&empData); err == nil && len(empData.Empresas) > 0 {
dba = empData.Empresas[0].DBA
}
resp.Body.Close()
}
// 2. Realizar login final
loginURL := baseURL + "/api/auth/login"
loginPayload := map[string]string{
"email": email,
"paswd": password,
}
if dba != "" {
loginPayload["dba"] = dba
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := client.Post(loginURL, "application/json", bytes.NewBuffer(loginBody))
if err != nil {
return nil, fmt.Errorf("error conectando con WebControl: %w", err)
}
defer loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(loginResp.Body)
return nil, fmt.Errorf("credenciales inválidas en WebControl (status %d): %s", loginResp.StatusCode, string(bodyBytes))
}
var res struct {
Token string `json:"token"`
APIKey string `json:"apiKey"`
Usuario struct {
ID interface{} `json:"id"`
Nombre string `json:"nombre"`
Email string `json:"email"`
} `json:"usuario"`
}
if err := json.NewDecoder(loginResp.Body).Decode(&res); err != nil {
return nil, fmt.Errorf("error interpretando respuesta de WebControl: %w", err)
}
userIDStr := fmt.Sprintf("wc_%v", res.Usuario.ID)
if res.Usuario.ID == nil || userIDStr == "wc_<nil>" {
userIDStr = "wc_" + a.GenerateToken(8)
}
userName := res.Usuario.Nombre
if userName == "" {
userName = strings.Split(email, "@")[0]
}
userEmail := res.Usuario.Email
if userEmail == "" {
userEmail = email
}
user := &models.User{
ID: userIDStr,
Provider: "webcontrol",
ProviderID: fmt.Sprintf("%v", res.Usuario.ID),
Email: userEmail,
Name: userName,
AvatarURL: "",
CreatedAt: time.Now(),
}
if err := a.db.UpsertUser(user); err != nil {
return nil, fmt.Errorf("error guardando usuario en base de datos: %w", err)
}
return user, nil
}
// Google OAuth
func (a *AuthService) GetGoogleAuthURL(state string) (string, error) {
if a.oauthConfig == nil {
return "", fmt.Errorf("Google OAuth no está configurado (faltan GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)")
}
return a.oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
func (a *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.User, error) {
if a.oauthConfig == nil {
return nil, fmt.Errorf("Google OAuth no está configurado")
}
token, err := a.oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("error intercambiando código OAuth: %w", err)
}
client := a.oauthConfig.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("error obteniendo perfil de Google: %w", err)
}
defer resp.Body.Close()
var gUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&gUser); err != nil {
return nil, fmt.Errorf("error leyendo perfil de Google: %w", err)
}
userID := "google_" + gUser.ID
user := &models.User{
ID: userID,
Provider: "google",
ProviderID: gUser.ID,
Email: gUser.Email,
Name: gUser.Name,
AvatarURL: gUser.Picture,
CreatedAt: time.Now(),
}
if err := a.db.UpsertUser(user); err != nil {
return nil, fmt.Errorf("error guardando usuario de Google: %w", err)
}
return user, nil
}
func (a *AuthService) HasGoogleConfigured() bool {
return a.oauthConfig != nil
}
+84
View File
@@ -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)
}
}
+72
View File
@@ -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)
}
}
}
}
+329
View File
@@ -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
}
+595
View File
@@ -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})
}
+60
View File
@@ -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])
}
+121
View File
@@ -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
}