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
This commit is contained in:
jlimolina 2026-08-10 15:25:41 +02:00
parent ecd7a3cdf8
commit a285021e6c
22 changed files with 7624 additions and 450 deletions

View file

@ -6,14 +6,15 @@ import (
"context"
"encoding/csv"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/config"
"github.com/rss2/backend/internal/db"
@ -637,17 +638,73 @@ func PatchEntityTipo(c *gin.Context) {
})
}
// pgConnInfo holds PostgreSQL connection parameters for pg_dump
type pgConnInfo struct {
host, port, name, user, pass string
}
// resolvePgConnInfo prefers DATABASE_URL (the variable the API container gets)
// and falls back to the individual DB_* variables used by the workers.
func resolvePgConnInfo() pgConnInfo {
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
if u, err := url.Parse(dbURL); err == nil && u.Hostname() != "" {
info := pgConnInfo{
host: u.Hostname(),
user: u.User.Username(),
name: strings.TrimPrefix(u.Path, "/"),
port: u.Port(),
}
if p, ok := u.User.Password(); ok {
info.pass = p
}
if info.port == "" {
info.port = "5432"
}
if info.user == "" {
info.user = "postgres"
}
if info.name == "" {
info.name = "postgres"
}
return info
}
}
info := pgConnInfo{
host: os.Getenv("DB_HOST"),
port: os.Getenv("DB_PORT"),
name: os.Getenv("DB_NAME"),
user: os.Getenv("DB_USER"),
pass: os.Getenv("DB_PASS"),
}
if info.host == "" {
info.host = "db"
}
if info.port == "" {
info.port = "5432"
}
if info.name == "" {
info.name = "rss"
}
if info.user == "" {
info.user = "rss"
}
return info
}
// BackupDatabase runs pg_dump and returns the SQL as a downloadable file
func BackupDatabase(c *gin.Context) {
// Ejecutar pg_dump desde dentro del contenedor db para evitar problemas de red
// Usamos docker exec para ejecutar el comando dentro del servicio db
cmd := exec.Command("docker", "exec", "rss2_db", "pg_dump",
"-U", os.Getenv("POSTGRES_USER"),
"-d", os.Getenv("POSTGRES_DB"),
info := resolvePgConnInfo()
cmd := exec.Command("pg_dump",
"-h", info.host,
"-p", info.port,
"-U", info.user,
"-d", info.name,
"--no-password",
"--format=plain",
"--pghost=5432",
)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
var out bytes.Buffer
var stderr bytes.Buffer
@ -671,32 +728,16 @@ func BackupDatabase(c *gin.Context) {
// BackupNewsZipped performs a pg_dump of news tables and returns a ZIP file
func BackupNewsZipped(c *gin.Context) {
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
dbHost = "db"
}
dbPort := os.Getenv("DB_PORT")
if dbPort == "" {
dbPort = "5432"
}
dbName := os.Getenv("DB_NAME")
if dbName == "" {
dbName = "rss"
}
dbUser := os.Getenv("DB_USER")
if dbUser == "" {
dbUser = "rss"
}
dbPass := os.Getenv("DB_PASS")
info := resolvePgConnInfo()
// Tables to backup
tables := []string{"noticias", "traducciones", "tags", "tags_noticia"}
args := []string{
"-h", dbHost,
"-p", dbPort,
"-U", dbUser,
"-d", dbName,
"-h", info.host,
"-p", info.port,
"-U", info.user,
"-d", info.name,
"--no-password",
}
@ -705,7 +746,7 @@ func BackupNewsZipped(c *gin.Context) {
}
cmd := exec.Command("pg_dump", args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPass))
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
var sqlOut bytes.Buffer
var stderr bytes.Buffer
@ -748,3 +789,107 @@ func BackupNewsZipped(c *gin.Context) {
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "application/zip", buf.Bytes())
}
// RestoreDatabase accepts an uploaded .sql or .zip backup and restores it via psql
func RestoreDatabase(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "No file uploaded",
"message": "Se requiere un archivo .sql o .zip",
})
return
}
uploaded, err := file.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open uploaded file"})
return
}
defer uploaded.Close()
data, err := io.ReadAll(uploaded)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read uploaded file"})
return
}
var sqlData []byte
lowerName := strings.ToLower(file.Filename)
switch {
case strings.HasSuffix(lowerName, ".sql"):
sqlData = data
case strings.HasSuffix(lowerName, ".zip"):
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ZIP file", "message": err.Error()})
return
}
found := false
for _, zf := range zr.File {
if zf.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(zf.Name), ".sql") {
continue
}
rc, err := zf.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to open SQL inside ZIP", "message": err.Error()})
return
}
sqlData, err = io.ReadAll(rc)
rc.Close()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read SQL inside ZIP", "message": err.Error()})
return
}
found = true
break
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "No SQL file found inside ZIP"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Unsupported file type",
"message": "Solo se permiten archivos .sql o .zip",
})
return
}
if len(sqlData) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Empty backup file"})
return
}
info := resolvePgConnInfo()
cmd := exec.Command("psql",
"-h", info.host,
"-p", info.port,
"-U", info.user,
"-d", info.name,
"--no-password",
"-v", "ON_ERROR_STOP=1",
)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", info.pass))
cmd.Stdin = bytes.NewReader(sqlData)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Restore failed",
"details": stderr.String(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Base de datos restaurada correctamente",
"filename": file.Filename,
"output": out.String(),
})
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
@ -397,11 +398,46 @@ func ImportFeeds(c *gin.Context) {
}
reader := csv.NewReader(strings.NewReader(string(content)))
_, err = reader.Read()
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
@ -415,71 +451,115 @@ func ImportFeeds(c *gin.Context) {
}
defer tx.Rollback(context.Background())
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
failed++
continue
field := func(record []string, idx int) string {
if idx < 0 || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
if len(record) < 4 {
for _, record := range dataRows {
if len(record) == 0 {
skipped++
continue
}
nombre := strings.TrimSpace(record[1])
url := strings.TrimSpace(record[3])
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 nombre == "" || url == "" {
if url == "" {
skipped++
continue
}
if nombre == "" {
nombre = nombreFromURL(url)
}
var descripcion *string
if len(record) > 2 && strings.TrimSpace(record[2]) != "" {
descripcionStr := strings.TrimSpace(record[2])
descripcion = &descripcionStr
}
var categoriaID *int64
if len(record) > 4 && strings.TrimSpace(record[4]) != "" {
catID, err := strconv.ParseInt(strings.TrimSpace(record[4]), 10, 64)
if err == nil {
categoriaID = &catID
}
}
var paisID *int64
if len(record) > 6 && strings.TrimSpace(record[6]) != "" {
pID, err := strconv.ParseInt(strings.TrimSpace(record[6]), 10, 64)
if err == nil {
paisID = &pID
}
}
var idioma *string
if len(record) > 8 && strings.TrimSpace(record[8]) != "" {
lang := strings.TrimSpace(record[8])
if len(lang) > 2 {
lang = lang[:2]
}
idioma = &lang
}
activo := true
if len(record) > 9 && strings.TrimSpace(record[9]) != "" {
activo = strings.ToLower(strings.TrimSpace(record[9])) == "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
}
}
}
var fallos int64
if len(record) > 10 && strings.TrimSpace(record[10]) != "" {
f, err := strconv.ParseInt(strings.TrimSpace(record[10]), 10, 64)
if err == nil {
fallos = f
}
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
@ -490,22 +570,25 @@ func ImportFeeds(c *gin.Context) {
WHERE id=$8`,
nombre, descripcion, categoriaID, paisID, idioma, activo, fallos, existingID,
)
if err != nil {
failed++
errors = append(errors, fmt.Sprintf("Error updating %s: %v", url, err))
continue
}
} 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 {
failed++
errors = append(errors, fmt.Sprintf("Error inserting %s: %v", url, err))
continue
}
}
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++
@ -525,6 +608,22 @@ func ImportFeeds(c *gin.Context) {
})
}
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 ""

View file

@ -51,55 +51,45 @@ func SearchNews(c *gin.Context) {
offset := (page - 1) * perPage
// Build dynamic query
args := []interface{}{}
argNum := 1
args := []interface{}{lang}
argNum := 2
whereClause := "WHERE 1=1"
if query != "" {
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + " OR n.contenido ILIKE $" + strconv.Itoa(argNum) + ")"
whereClause += " AND (n.titulo ILIKE $" + strconv.Itoa(argNum) + " OR n.resumen ILIKE $" + strconv.Itoa(argNum) + ")"
args = append(args, "%"+query+"%")
argNum++
}
if lang != "" {
whereClause += " AND t.lang_to = $" + strconv.Itoa(argNum)
args = append(args, lang)
argNum++
}
if categoriaID != "" {
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
catID, err := strconv.ParseInt(categoriaID, 10, 64)
if err == nil {
if catID, err := strconv.ParseInt(categoriaID, 10, 64); err == nil {
whereClause += " AND n.categoria_id = $" + strconv.Itoa(argNum)
args = append(args, catID)
argNum++
}
}
if paisID != "" {
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
pID, err := strconv.ParseInt(paisID, 10, 64)
if err == nil {
if pID, err := strconv.ParseInt(paisID, 10, 64); err == nil {
whereClause += " AND n.pais_id = $" + strconv.Itoa(argNum)
args = append(args, pID)
argNum++
}
}
args = append(args, perPage, offset)
sqlQuery := `
SELECT n.id, n.titulo, n.resumen, n.contenido, n.url, n.imagen,
n.feed_id, n.lang, n.categoria_id, n.pais_id, n.created_at, n.updated_at,
COALESCE(t.titulo_trad, '') as titulo_trad,
COALESCE(t.resumen_trad, '') as resumen_trad,
t.lang_to as lang_trad,
f.nombre as fuente_nombre
SELECT n.id, n.titulo, COALESCE(n.resumen, ''), n.url, n.fecha, n.imagen_url,
n.categoria_id, n.pais_id, n.fuente_nombre,
t.titulo_trad,
t.resumen_trad,
t.lang_to as lang_trad
FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $` + strconv.Itoa(argNum) + `
LEFT JOIN feeds f ON f.id = n.feed_id
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
` + whereClause + `
ORDER BY n.created_at DESC
LIMIT $` + strconv.Itoa(argNum+1) + ` OFFSET $` + strconv.Itoa(argNum+2)
ORDER BY n.fecha DESC
LIMIT $` + strconv.Itoa(argNum) + ` OFFSET $` + strconv.Itoa(argNum+1)
args = append(args, perPage, offset)
rows, err := db.GetPool().Query(ctx, sqlQuery, args...)
if err != nil {
@ -108,33 +98,38 @@ func SearchNews(c *gin.Context) {
}
defer rows.Close()
var newsList []models.NewsWithTranslations
var newsList []NewsResponse
for rows.Next() {
var n models.NewsWithTranslations
var imagen *string
var n NewsResponse
var imagenURL, fuenteNombre *string
var categoriaIDp, paisIDp *int64
err := rows.Scan(
&n.ID, &n.Title, &n.Summary, &n.Content, &n.URL, &imagen,
&n.FeedID, &n.Lang, &n.CategoryID, &n.CountryID, &n.CreatedAt, &n.UpdatedAt,
&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &imagenURL,
&categoriaIDp, &paisIDp, &fuenteNombre,
&n.TitleTranslated, &n.SummaryTranslated, &n.LangTranslated,
)
if err != nil {
continue
}
if imagen != nil {
n.ImageURL = imagen
if imagenURL != nil {
n.ImagenURL = imagenURL
}
if fuenteNombre != nil {
n.FuenteNombre = *fuenteNombre
}
n.CategoriaID = categoriaIDp
n.PaisID = paisIDp
newsList = append(newsList, n)
}
// Get total count
countArgs := args[:len(args)-2]
// Remove LIMIT/OFFSET from args for count
var total int
err = db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM noticias n
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $`+strconv.Itoa(argNum)+`
LEFT JOIN traducciones t ON t.noticia_id = n.id AND t.lang_to = $1
`+whereClause, countArgs...).Scan(&total)
if err != nil {
total = len(newsList)
@ -142,15 +137,13 @@ func SearchNews(c *gin.Context) {
totalPages := (total + perPage - 1) / perPage
response := models.NewsListResponse{
News: newsList,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
c.JSON(http.StatusOK, response)
c.JSON(http.StatusOK, gin.H{
"news": newsList,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
func GetStats(c *gin.Context) {

View file

@ -132,7 +132,10 @@ func writeLoop(wsWorker *WSWorker) {
for {
select {
case <-ticker.C:
if err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
wsWorker.mu.Lock()
err := wsWorker.conn.WriteMessage(websocket.PingMessage, nil)
wsWorker.mu.Unlock()
if err != nil {
return
}
}
@ -163,7 +166,9 @@ func heartbeatLoop(wsWorker *WSWorker) {
func sendWS(wsWorker *WSWorker, msg models.WSServerMessage) {
data, _ := json.Marshal(msg)
wsWorker.mu.Lock()
wsWorker.conn.WriteMessage(websocket.TextMessage, data)
wsWorker.mu.Unlock()
}
func cleanupWorker(wsWorker *WSWorker, apiKey string) {