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