rss2/backend/internal/handlers/feed.go
jlimolina a285021e6c fix(backup): corregir descarga y añadir restauración de BD
- Backend usa DATABASE_URL (resolvePgConnInfo) en vez de DB_* que no
  existían en el contenedor API, arreglando el fallo de pg_dump
- Dockerfile backend: alpine 3.23 para pg_dump 18.4 (compatible con
  PostgreSQL 18 del servidor)
- BackupDatabase ya no usa docker exec (no disponible en la imagen)
- Nuevo endpoint POST /api/admin/restore para restaurar .sql o .zip vía psql
- UI de Configuración: sección Restaurar Base de Datos
- Regenerado go.sum para el build
2026-08-10 15:25:41 +02:00

639 lines
17 KiB
Go

package handlers
import (
"context"
"encoding/csv"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
)
type FeedResponse struct {
ID int64 `json:"id"`
Nombre string `json:"nombre"`
Descripcion *string `json:"descripcion"`
URL string `json:"url"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
Activo bool `json:"activo"`
Fallos *int64 `json:"fallos"`
LastError *string `json:"last_error"`
FuenteURLID *int64 `json:"fuente_url_id"`
}
func GetFeeds(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
activo := c.Query("activo")
categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id")
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 50
}
offset := (page - 1) * perPage
where := "1=1"
args := []interface{}{}
argNum := 1
if activo != "" {
where += fmt.Sprintf(" AND activo = $%d", argNum)
args = append(args, activo == "true")
argNum++
}
if categoriaID != "" {
where += fmt.Sprintf(" AND categoria_id = $%d", argNum)
args = append(args, categoriaID)
argNum++
}
if paisID != "" {
where += fmt.Sprintf(" AND pais_id = $%d", argNum)
args = append(args, paisID)
argNum++
}
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feeds WHERE %s", where)
err := db.GetPool().QueryRow(c.Request.Context(), countQuery, args...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count feeds", Message: err.Error()})
return
}
sqlQuery := fmt.Sprintf(`
SELECT f.id, f.nombre, f.descripcion, f.url,
f.categoria_id, f.pais_id, f.idioma, f.activo, f.fallos, f.last_error,
c.nombre AS categoria, p.nombre AS pais,
(SELECT COUNT(*) FROM noticias n WHERE n.fuente_nombre = f.nombre) as noticias_count
FROM feeds f
LEFT JOIN categorias c ON c.id = f.categoria_id
LEFT JOIN paises p ON p.id = f.pais_id
WHERE %s
ORDER BY p.nombre NULLS LAST, f.activo DESC, f.fallos ASC, c.nombre NULLS LAST, f.nombre
LIMIT $%d OFFSET $%d
`, where, argNum, argNum+1)
args = append(args, perPage, offset)
rows, err := db.GetPool().Query(c.Request.Context(), sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return
}
defer rows.Close()
type FeedWithStats struct {
FeedResponse
Categoria *string `json:"categoria"`
Pais *string `json:"pais"`
NoticiasCount int64 `json:"noticias_count"`
}
var feeds []FeedWithStats
for rows.Next() {
var f FeedWithStats
err := rows.Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos, &f.LastError,
&f.Categoria, &f.Pais, &f.NoticiasCount,
)
if err != nil {
continue
}
feeds = append(feeds, f)
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
"feeds": feeds,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
func GetFeedByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
var f FeedResponse
err = db.GetPool().QueryRow(c.Request.Context(), `
SELECT id, nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos
FROM feeds WHERE id = $1`, id).Scan(
&f.ID, &f.Nombre, &f.Descripcion, &f.URL,
&f.CategoriaID, &f.PaisID, &f.Idioma, &f.Activo, &f.Fallos,
)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, f)
}
type CreateFeedRequest struct {
Nombre string `json:"nombre" binding:"required"`
URL string `json:"url" binding:"required,url"`
Descripcion *string `json:"descripcion"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
}
func CreateFeed(c *gin.Context) {
var req CreateFeedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request", Message: err.Error()})
return
}
var feedID int64
err := db.GetPool().QueryRow(c.Request.Context(), `
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
req.Nombre, req.Descripcion, req.URL, req.CategoriaID, req.PaisID, req.Idioma,
).Scan(&feedID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to create feed", Message: err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": feedID, "message": "Feed created successfully"})
}
type UpdateFeedRequest struct {
Nombre string `json:"nombre" binding:"required"`
URL string `json:"url" binding:"required,url"`
Descripcion *string `json:"descripcion"`
CategoriaID *int64 `json:"categoria_id"`
PaisID *int64 `json:"pais_id"`
Idioma *string `json:"idioma"`
Activo *bool `json:"activo"`
}
func UpdateFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
var req UpdateFeedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request", Message: err.Error()})
return
}
activeVal := true
if req.Activo != nil {
activeVal = *req.Activo
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds
SET nombre = $1, descripcion = $2, url = $3,
categoria_id = $4, pais_id = $5, idioma = $6, activo = $7
WHERE id = $8`,
req.Nombre, req.Descripcion, req.URL,
req.CategoriaID, req.PaisID, req.Idioma, activeVal, id,
)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed updated successfully"})
}
func DeleteFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), "DELETE FROM feeds WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to delete feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed deleted successfully"})
}
func ToggleFeedActive(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds SET activo = NOT activo WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to toggle feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed toggled successfully"})
}
func ReactivateFeed(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid feed ID"})
return
}
result, err := db.GetPool().Exec(c.Request.Context(), `
UPDATE feeds SET activo = TRUE, fallos = 0 WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to reactivate feed", Message: err.Error()})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Feed not found"})
return
}
c.JSON(http.StatusOK, models.SuccessResponse{Message: "Feed reactivated successfully"})
}
func ExportFeeds(c *gin.Context) {
activo := c.Query("activo")
categoriaID := c.Query("categoria_id")
paisID := c.Query("pais_id")
where := "1=1"
args := []interface{}{}
argNum := 1
if activo != "" {
where += fmt.Sprintf(" AND activo = $%d", argNum)
args = append(args, activo == "true")
argNum++
}
if categoriaID != "" {
where += fmt.Sprintf(" AND categoria_id = $%d", argNum)
args = append(args, categoriaID)
argNum++
}
if paisID != "" {
where += fmt.Sprintf(" AND pais_id = $%d", argNum)
args = append(args, paisID)
argNum++
}
query := fmt.Sprintf(`
SELECT f.id, f.nombre, f.descripcion, f.url,
f.categoria_id, c.nombre AS categoria,
f.pais_id, p.nombre AS pais,
f.idioma, f.activo, f.fallos
FROM feeds f
LEFT JOIN categorias c ON c.id = f.categoria_id
LEFT JOIN paises p ON p.id = f.pais_id
WHERE %s
ORDER BY f.id
`, where)
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to fetch feeds", Message: err.Error()})
return
}
defer rows.Close()
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=feeds_export.csv")
writer := csv.NewWriter(c.Writer)
defer writer.Flush()
writer.Write([]string{"id", "nombre", "descripcion", "url", "categoria_id", "categoria", "pais_id", "pais", "idioma", "activo", "fallos"})
for rows.Next() {
var id int64
var nombre, url string
var descripcion, idioma *string
var categoriaID, paisID, fallos *int64
var activo bool
var categoria, pais *string
err := rows.Scan(&id, &nombre, &descripcion, &url, &categoriaID, &categoria, &paisID, &pais, &idioma, &activo, &fallos)
if err != nil {
continue
}
writer.Write([]string{
fmt.Sprintf("%d", id),
nombre,
stringOrEmpty(descripcion),
url,
int64ToString(categoriaID),
stringOrEmpty(categoria),
int64ToString(paisID),
stringOrEmpty(pais),
stringOrEmpty(idioma),
fmt.Sprintf("%t", activo),
int64ToString(fallos),
})
}
}
func ImportFeeds(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "No file provided"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to open file", Message: err.Error()})
return
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to read file", Message: err.Error()})
return
}
reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll()
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid CSV format"})
return
}
if len(records) == 0 {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Empty CSV file"})
return
}
colIndex := func(header []string, names ...string) int {
for i, h := range header {
h = strings.ToLower(strings.TrimSpace(h))
for _, n := range names {
if h == n {
return i
}
}
}
return -1
}
header := records[0]
isHeader := colIndex(header, "url", "link", "feed", "nombre", "name", "titulo") != -1
var dataRows [][]string
iNombre, iDesc, iURL, iCat, iPais, iLang, iActivo, iFallos := -1, -1, -1, -1, -1, -1, -1, -1
if isHeader {
iNombre = colIndex(header, "nombre", "name", "titulo", "title")
iDesc = colIndex(header, "descripcion", "description")
iURL = colIndex(header, "url", "link", "feed")
iCat = colIndex(header, "categoria_id", "category_id", "categoria", "category")
iPais = colIndex(header, "pais_id", "country_id", "pais", "country")
iLang = colIndex(header, "idioma", "language", "lang")
iActivo = colIndex(header, "activo", "active", "enabled")
iFallos = colIndex(header, "fallos", "failures", "fails")
dataRows = records[1:]
} else {
dataRows = records
}
imported := 0
skipped := 0
failed := 0
errors := []string{}
tx, err := db.GetPool().Begin(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to start transaction", Message: err.Error()})
return
}
defer tx.Rollback(context.Background())
field := func(record []string, idx int) string {
if idx < 0 || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
for _, record := range dataRows {
if len(record) == 0 {
skipped++
continue
}
var nombre, url string
if isHeader {
nombre = field(record, iNombre)
url = field(record, iURL)
} else if len(record) == 1 {
url = field(record, 0)
} else if len(record) == 2 {
a := field(record, 0)
b := field(record, 1)
if looksLikeURL(a) {
url, nombre = a, b
} else {
nombre, url = a, b
}
} else {
nombre = field(record, 1)
url = field(record, 3)
}
if url == "" {
skipped++
continue
}
if nombre == "" {
nombre = nombreFromURL(url)
}
var descripcion *string
var categoriaID *int64
var paisID *int64
var idioma *string
activo := true
var fallos int64
if isHeader {
if d := field(record, iDesc); d != "" {
descripcion = &d
}
if v := field(record, iCat); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
categoriaID = &id
}
}
if v := field(record, iPais); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
paisID = &id
}
}
if l := field(record, iLang); l != "" {
if len(l) > 2 {
l = l[:2]
}
idioma = &l
}
if a := field(record, iActivo); a != "" {
activo = strings.ToLower(a) == "true"
}
if f := field(record, iFallos); f != "" {
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
fallos = v
}
}
} else {
if d := field(record, 2); d != "" {
descripcion = &d
}
if v := field(record, 4); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
categoriaID = &id
}
}
if v := field(record, 6); v != "" {
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
paisID = &id
}
}
if l := field(record, 8); l != "" {
if len(l) > 2 {
l = l[:2]
}
idioma = &l
}
if a := field(record, 9); a != "" {
activo = strings.ToLower(a) == "true"
}
if f := field(record, 10); f != "" {
if v, err := strconv.ParseInt(f, 10, 64); err == nil {
fallos = v
}
}
}
if _, err := tx.Exec(context.Background(), "SAVEPOINT sp"); err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
continue
}
var existingID int64
err = tx.QueryRow(context.Background(), "SELECT id FROM feeds WHERE url = $1", url).Scan(&existingID)
if err == nil {
_, err = tx.Exec(context.Background(), `
UPDATE feeds SET nombre=$1, descripcion=$2, categoria_id=$3, pais_id=$4, idioma=$5, activo=$6, fallos=$7
WHERE id=$8`,
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
)
} else {
_, err = tx.Exec(context.Background(), `
INSERT INTO feeds (nombre, descripcion, url, categoria_id, pais_id, idioma, activo, fallos)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
nombre, descripcion, url, categoriaID, paisID, idioma, activo, fallos,
)
}
if err != nil {
tx.Exec(context.Background(), "ROLLBACK TO SAVEPOINT sp")
failed++
errors = append(errors, fmt.Sprintf("Error upserting %s: %v", url, err))
continue
}
if _, err := tx.Exec(context.Background(), "RELEASE SAVEPOINT sp"); err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error for %s: %v", url, err))
continue
}
imported++
}
if err := tx.Commit(context.Background()); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to commit transaction", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"failed": failed,
"errors": errors,
"message": fmt.Sprintf("Import completed. Imported: %d, Skipped: %d, Failed: %d", imported, skipped, failed),
})
}
func looksLikeURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://www.") || strings.HasPrefix(s, "https://www.") || strings.HasPrefix(s, "www.") || strings.HasPrefix(s, "feed://")
}
func nombreFromURL(u string) string {
u = strings.TrimSpace(u)
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "https://" + u
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
return u
}
return strings.TrimPrefix(parsed.Host, "www.")
}
func stringOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
func int64ToString(i *int64) string {
if i == nil {
return ""
}
return fmt.Sprintf("%d", *i)
}