added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,110 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"pinepods/gpodder-api/config"
"pinepods/gpodder-api/internal/api"
"pinepods/gpodder-api/internal/db"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables from .env file if it exists
_ = godotenv.Load()
// Debug log environment variables
fmt.Printf("Environment variables:\n")
fmt.Printf("DB_TYPE: %s\n", os.Getenv("DB_TYPE"))
fmt.Printf("DB_HOST: %s\n", os.Getenv("DB_HOST"))
fmt.Printf("DB_PORT: %s\n", os.Getenv("DB_PORT"))
fmt.Printf("DB_USER: %s\n", os.Getenv("DB_USER"))
fmt.Printf("DB_NAME: %s\n", os.Getenv("DB_NAME"))
fmt.Printf("DB_PASSWORD: [hidden]\n")
// Initialize configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
fmt.Printf("Using database type: %s\n", cfg.Database.Type)
// Initialize database - use Database type instead of PostgresDB
var database *db.Database
database, err = db.NewDatabase(cfg.Database)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
// Set Gin mode
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize router
router := gin.Default()
// Setup middleware
router.Use(gin.Recovery())
router.Use(gin.Logger())
// Add CORS middleware
router.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Register API routes
apiRoutes := router.Group("/api/2")
api.RegisterRoutes(apiRoutes, database)
// Register simple API routes (v1)
simpleAPIRoutes := router.Group("")
api.RegisterSimpleRoutes(simpleAPIRoutes, database)
// Start server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
// Graceful shutdown
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Set timeout for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exiting")
}

View File

@@ -0,0 +1,93 @@
package config
import (
"os"
"strconv"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
Environment string
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
Port int
}
// DatabaseConfig holds database-related configuration
type DatabaseConfig struct {
Host string
Port int
User string
Password string
DBName string
SSLMode string
Type string // "postgresql" or "mysql"
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
// Server configuration
serverPort, err := strconv.Atoi(getEnv("SERVER_PORT", "8080"))
if err != nil {
serverPort = 8080
}
// Database configuration - use DB_* environment variables
dbPort, err := strconv.Atoi(getEnv("DB_PORT", "5432"))
if err != nil {
dbPort = 5432
}
// Get database type - defaults to postgresql if not specified
dbType := getEnv("DB_TYPE", "postgresql")
// Set default port based on database type if not explicitly provided
if os.Getenv("DB_PORT") == "" {
if dbType == "mysql" {
dbPort = 3306
} else {
dbPort = 5432
}
}
// Set default user based on database type if not explicitly provided
dbUser := getEnv("DB_USER", "")
if dbUser == "" {
if dbType == "mysql" {
// Use root user for MySQL by default
dbUser = "root"
} else {
// Use postgres user for PostgreSQL by default
dbUser = "postgres"
}
}
return &Config{
Server: ServerConfig{
Port: serverPort,
},
Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"),
Port: dbPort,
User: dbUser,
Password: getEnv("DB_PASSWORD", "password"),
DBName: getEnv("DB_NAME", "pinepods_database"),
SSLMode: getEnv("DB_SSL_MODE", "disable"),
Type: dbType,
},
Environment: getEnv("ENVIRONMENT", "development"),
}, nil
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, defaultValue string) string {
value, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
return value
}

View File

@@ -0,0 +1,46 @@
module pinepods/gpodder-api
go 1.24
require (
github.com/alexedwards/argon2id v1.0.0
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.9.2
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/mmcdole/gofeed v1.3.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,170 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM=
github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,850 @@
// Package api provides the API endpoints for the gpodder API
package api
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"log"
"net/http"
"strings"
"time"
"pinepods/gpodder-api/internal/db"
"github.com/alexedwards/argon2id"
"github.com/fernet/fernet-go"
"github.com/gin-gonic/gin"
)
// Define the parameters we use for Argon2id
type argon2Params struct {
memory uint32
iterations uint32
parallelism uint8
saltLength uint32
keyLength uint32
}
// AuthMiddleware creates a middleware for authentication
func AuthMiddleware(db *db.PostgresDB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get the username from the URL parameters
username := c.Param("username")
if username == "" {
log.Printf("[ERROR] AuthMiddleware: Username parameter is missing in path")
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
c.Abort()
return
}
// Check if this is an internal API call via X-GPodder-Token
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
if gpodderTokenHeader != "" {
// Get user data
var userID int
var gpodderToken sql.NullString
var podSyncType string
err := db.QueryRow(`
SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
WHERE LOWER(Username) = LOWER($1)
`, username).Scan(&userID, &gpodderToken, &podSyncType)
if err != nil {
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
c.Abort()
return
}
// Check if gpodder sync is enabled
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
username, podSyncType)
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
c.Abort()
return
}
// For internal calls with X-GPodder-Token header, validate token directly
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
c.Set("userID", userID)
c.Set("username", username)
c.Next()
return
}
// If token doesn't match, authentication failed
log.Printf("[ERROR] AuthMiddleware: Invalid X-GPodder-Token for user: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// If no token header found, proceed with standard authentication
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
log.Printf("[ERROR] AuthMiddleware: Authorization header is missing")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
// Extract credentials
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Basic" {
log.Printf("[ERROR] AuthMiddleware: Invalid Authorization header format")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
c.Abort()
return
}
// Decode credentials
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
log.Printf("[ERROR] AuthMiddleware: Failed to decode base64 credentials: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
c.Abort()
return
}
// Extract username and password
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
log.Printf("[ERROR] AuthMiddleware: Invalid credentials format")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
c.Abort()
return
}
authUsername := credentials[0]
password := credentials[1]
// Check username match
if strings.ToLower(username) != strings.ToLower(authUsername) {
log.Printf("[ERROR] AuthMiddleware: Username mismatch - URL: %s, Auth: %s",
strings.ToLower(username), strings.ToLower(authUsername))
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
c.Abort()
return
}
// Query user data
var userID int
var hashedPassword string
var podSyncType string
var gpodderToken sql.NullString
err = db.QueryRow(`
SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
WHERE LOWER(Username) = LOWER($1)
`, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("[ERROR] AuthMiddleware: User not found: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
} else {
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
c.Abort()
return
}
// Check if gpodder sync is enabled
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
username, podSyncType)
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
c.Abort()
return
}
// Flag to track authentication success
authenticated := false
// Check if this is a gpodder token authentication
// Check if this is a gpodder token authentication
if gpodderToken.Valid && (gpodderToken.String == password || gpodderToken.String == gpodderTokenHeader) {
authenticated = true
}
// If token auth didn't succeed, try password authentication
if !authenticated && verifyPassword(password, hashedPassword) {
authenticated = true
}
// If authentication was successful, set context and continue
if authenticated {
c.Set("userID", userID)
c.Set("username", username)
c.Next()
return
}
// Authentication failed
log.Printf("[ERROR] AuthMiddleware: Invalid credentials for user: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
c.Abort()
}
}
// Helper function to decrypt token
func decryptToken(encryptionKey []byte, encryptedToken string) (string, error) {
// Ensure the encryptionKey is correctly formatted for fernet
// Fernet requires a 32-byte key encoded in base64
keyStr := base64.StdEncoding.EncodeToString(encryptionKey)
// Parse the key
key, err := fernet.DecodeKey(keyStr)
if err != nil {
return "", fmt.Errorf("failed to decode key: %w", err)
}
// Decrypt the token
token := []byte(encryptedToken)
msg := fernet.VerifyAndDecrypt(token, 0, []*fernet.Key{key})
if msg == nil {
return "", fmt.Errorf("failed to decrypt token or token invalid")
}
return string(msg), nil
}
// generateSessionToken generates a random token for sessions
func generateSessionToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// createSession creates a new session in the database
func createSession(db *db.Database, userID int, userAgent, clientIP string) (string, time.Time, error) {
// Generate a random session token
token, err := generateSessionToken()
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to generate session token: %w", err)
}
// Set expiration time (30 days from now)
expires := time.Now().Add(30 * 24 * time.Hour)
// Insert session into database
_, err = db.Exec(`
INSERT INTO "GpodderSessions" (UserID, SessionToken, ExpiresAt, UserAgent, ClientIP)
VALUES ($1, $2, $3, $4, $5)
`, userID, token, expires, userAgent, clientIP)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to create session: %w", err)
}
return token, expires, nil
}
// validateSession validates a session token
func validateSession(db *db.Database, token string) (int, bool, error) {
var userID int
var expires time.Time
var query string
// Format query according to database type
if db.IsPostgreSQLDB() {
query = `SELECT UserID, ExpiresAt FROM "GpodderSessions" WHERE SessionToken = $1`
} else {
query = `SELECT UserID, ExpiresAt FROM GpodderSessions WHERE SessionToken = ?`
}
err := db.QueryRow(query, token).Scan(&userID, &expires)
if err != nil {
if err == sql.ErrNoRows {
return 0, false, nil // Session not found
}
return 0, false, fmt.Errorf("error validating session: %w", err)
}
// Check if session has expired
if time.Now().After(expires) {
// Delete expired session
if db.IsPostgreSQLDB() {
query = `DELETE FROM "GpodderSessions" WHERE SessionToken = $1`
} else {
query = `DELETE FROM GpodderSessions WHERE SessionToken = ?`
}
_, err = db.Exec(query, token)
if err != nil {
log.Printf("Failed to delete expired session: %v", err)
}
return 0, false, nil
}
// Update last active time
if db.IsPostgreSQLDB() {
query = `UPDATE "GpodderSessions" SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = $1`
} else {
query = `UPDATE GpodderSessions SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = ?`
}
_, err = db.Exec(query, token)
if err != nil {
log.Printf("Failed to update session last active time: %v", err)
}
return userID, true, nil
}
// deleteSession removes a session from the database
func deleteSession(db *db.Database, token string) error {
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE SessionToken = $1`, token)
if err != nil {
return fmt.Errorf("failed to delete session: %w", err)
}
return nil
}
// deleteUserSessions removes all sessions for a user
func deleteUserSessions(db *db.PostgresDB, userID int) error {
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE UserID = $1`, userID)
if err != nil {
return fmt.Errorf("failed to delete user sessions: %w", err)
}
return nil
}
// handleLogin enhanced with session management
func handleLogin(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Use the AuthMiddleware to authenticate the user
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
// Get the Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
return
}
// Check if the Authorization header is in the correct format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Basic" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
return
}
// Decode the base64-encoded credentials
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
return
}
// Extract username and password
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
return
}
authUsername := credentials[0]
password := credentials[1]
// Verify that the username in the URL matches the one in the Authorization header
if strings.ToLower(username) != strings.ToLower(authUsername) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
return
}
// Check if the user exists and the password is correct
var userID int
var hashedPassword string
var podSyncType string
// Make sure to use case-insensitive username lookup
err = database.QueryRow(`
SELECT UserID, Hashed_PW, Pod_Sync_Type FROM "Users" WHERE LOWER(Username) = LOWER($1)
`, username).Scan(&userID, &hashedPassword, &podSyncType)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
} else {
log.Printf("Database error during login: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Check if gpodder sync is enabled for this user
if podSyncType != "gpodder" && podSyncType != "both" {
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
return
}
// Verify password using Pinepods' Argon2 password method
if !verifyPassword(password, hashedPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
// Create a new session
userAgent := c.Request.UserAgent()
clientIP := c.ClientIP()
sessionToken, expiresAt, err := createSession(database, userID, userAgent, clientIP)
if err != nil {
log.Printf("Failed to create session: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
log.Printf("[DEBUG] handleLogin: Login successful for user: %s, created session token (first 8 chars): %s...",
username, sessionToken[:8])
// Set session cookie
c.SetCookie(
"sessionid", // name
sessionToken, // value
int(30*24*time.Hour.Seconds()), // max age in seconds (30 days)
"/", // path
"", // domain (empty = current domain)
c.Request.TLS != nil, // secure (HTTPS only)
true, // httpOnly (not accessible via JavaScript)
)
log.Printf("[DEBUG] handleLogin: Sending response with session expiry: %s",
expiresAt.Format(time.RFC3339))
// Return success with info
c.JSON(http.StatusOK, gin.H{
"status": "success",
"userid": userID,
"username": username,
"session_expires": expiresAt.Format(time.RFC3339),
})
}
}
// handleLogout enhanced with session management
func handleLogout(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get username from URL
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
// Get the session cookie
sessionToken, err := c.Cookie("sessionid")
if err != nil || sessionToken == "" {
// No session cookie, just return success (idempotent operation)
c.JSON(http.StatusOK, gin.H{
"status": "logged out",
})
return
}
// Delete the session
err = deleteSession(database, sessionToken)
if err != nil {
log.Printf("Error deleting session: %v", err)
// Continue anyway - we still want to invalidate the cookie
}
// Clear the session cookie
c.SetCookie(
"sessionid", // name
"", // value (empty = delete)
-1, // max age (negative = delete)
"/", // path
"", // domain
c.Request.TLS != nil, // secure
true, // httpOnly
)
c.JSON(http.StatusOK, gin.H{
"status": "logged out",
})
}
}
// SessionMiddleware checks if a user is logged in via session
func SessionMiddleware(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
log.Printf("[DEBUG] SessionMiddleware processing request: %s %s",
c.Request.Method, c.Request.URL.Path)
// First, try to get user from Authorization header for direct API access
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
log.Printf("[DEBUG] SessionMiddleware: Authorization header found, passing to next middleware")
c.Next()
return
}
// No Authorization header, check for session cookie
sessionToken, err := c.Cookie("sessionid")
if err != nil || sessionToken == "" {
log.Printf("[ERROR] SessionMiddleware: No session cookie found: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
c.Abort()
return
}
log.Printf("[DEBUG] SessionMiddleware: Found session cookie, validating")
// Validate the session
userID, valid, err := validateSession(database, sessionToken)
if err != nil {
log.Printf("[ERROR] SessionMiddleware: Error validating session: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"})
c.Abort()
return
}
if !valid {
log.Printf("[ERROR] SessionMiddleware: Invalid or expired session")
// Clear the invalid cookie
c.SetCookie("sessionid", "", -1, "/", "", c.Request.TLS != nil, true)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
c.Abort()
return
}
log.Printf("[DEBUG] SessionMiddleware: Session valid for userID: %d", userID)
// Get the username for the user ID
var username string
err = database.QueryRow(`SELECT Username FROM "Users" WHERE UserID = $1`, userID).Scan(&username)
if err != nil {
log.Printf("[ERROR] SessionMiddleware: Error getting username for userID %d: %v",
userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
c.Abort()
return
}
// Check if gpodder sync is enabled for this user
var podSyncType string
err = database.QueryRow(`SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`, userID).Scan(&podSyncType)
if err != nil {
log.Printf("[ERROR] SessionMiddleware: Error checking sync type for userID %d: %v",
userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
c.Abort()
return
}
if podSyncType != "gpodder" && podSyncType != "both" {
log.Printf("[ERROR] SessionMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
username, podSyncType)
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
c.Abort()
return
}
// Set the user information in the context
c.Set("userID", userID)
c.Set("username", username)
// Check if the path username matches the session username
pathUsername := c.Param("username")
if pathUsername != "" && strings.ToLower(pathUsername) != strings.ToLower(username) {
log.Printf("[ERROR] SessionMiddleware: Username mismatch - Path: %s, Session: %s",
pathUsername, username)
c.JSON(http.StatusForbidden, gin.H{"error": "Username mismatch"})
c.Abort()
return
}
log.Printf("[DEBUG] SessionMiddleware: Session authentication successful for user: %s", username)
c.Next()
}
}
// AuthenticationMiddleware with GPodder token handling
func AuthenticationMiddleware(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
log.Printf("[DEBUG] AuthenticationMiddleware processing request: %s %s",
c.Request.Method, c.Request.URL.Path)
// Handle GPodder API standard .json suffix patterns
if strings.HasSuffix(c.Request.URL.Path, ".json") {
parts := strings.Split(c.Request.URL.Path, "/")
var username string
// Handle /episodes/username.json pattern
if strings.Contains(c.Request.URL.Path, "/episodes/") && len(parts) >= 3 {
usernameWithExt := parts[len(parts)-1]
username = strings.TrimSuffix(usernameWithExt, ".json")
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from episode actions URL", username)
}
// Handle /devices/username.json pattern
if strings.Contains(c.Request.URL.Path, "/devices/") {
for i, part := range parts {
if part == "devices" && i+1 < len(parts) {
usernameWithExt := parts[i+1]
username = strings.TrimSuffix(usernameWithExt, ".json")
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from devices URL", username)
break
}
}
}
// Set username parameter if extracted
if username != "" {
c.Params = append(c.Params, gin.Param{Key: "username", Value: username})
}
}
// First try session auth
sessionToken, err := c.Cookie("sessionid")
if err == nil && sessionToken != "" {
log.Printf("[DEBUG] AuthenticationMiddleware: Found session cookie, validating")
userID, valid, err := validateSession(database, sessionToken)
if err == nil && valid {
log.Printf("[DEBUG] AuthenticationMiddleware: Session valid for userID: %d", userID)
var username string
var query string
// Format query according to database type
if database.IsPostgreSQLDB() {
query = `SELECT Username FROM "Users" WHERE UserID = $1`
} else {
query = `SELECT Username FROM Users WHERE UserID = ?`
}
err = database.QueryRow(query, userID).Scan(&username)
if err == nil {
var podSyncType string
// Format query according to database type
if database.IsPostgreSQLDB() {
query = `SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`
} else {
query = `SELECT Pod_Sync_Type FROM Users WHERE UserID = ?`
}
err = database.QueryRow(query, userID).Scan(&podSyncType)
if err == nil && (podSyncType == "gpodder" || podSyncType == "both") {
// Check if the path username matches the session username
pathUsername := c.Param("username")
if pathUsername == "" || strings.ToLower(pathUsername) == strings.ToLower(username) {
log.Printf("[DEBUG] AuthenticationMiddleware: Session auth successful for user: %s",
username)
c.Set("userID", userID)
c.Set("username", username)
c.Next()
return
} else {
log.Printf("[ERROR] AuthenticationMiddleware: Session username mismatch - Path: %s, Session: %s",
pathUsername, username)
}
} else {
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder not enabled for user: %s", username)
}
} else {
log.Printf("[ERROR] AuthenticationMiddleware: Could not get username for userID %d: %v",
userID, err)
}
} else {
log.Printf("[ERROR] AuthenticationMiddleware: Invalid session: %v", err)
}
} else {
log.Printf("[DEBUG] AuthenticationMiddleware: No session cookie, falling back to basic auth")
}
// Try basic auth if session auth failed
log.Printf("[DEBUG] AuthenticationMiddleware: Attempting basic auth")
username := c.Param("username")
if username == "" {
log.Printf("[ERROR] AuthenticationMiddleware: Username parameter is missing in path")
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
return
}
// Check if this is an internal API call via X-GPodder-Token
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
if gpodderTokenHeader != "" {
log.Printf("[DEBUG] AuthenticationMiddleware: Found X-GPodder-Token header")
// Get user data
var userID int
var gpodderToken sql.NullString
var podSyncType string
var query string
// Format query according to database type
if database.IsPostgreSQLDB() {
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
WHERE LOWER(Username) = LOWER($1)`
} else {
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM Users
WHERE LOWER(Username) = LOWER(?)`
}
err := database.QueryRow(query, username).Scan(&userID, &gpodderToken, &podSyncType)
if err != nil {
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
return
}
// Check if gpodder sync is enabled
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
username, podSyncType)
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
return
}
// For internal calls with X-GPodder-Token header, validate token directly
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
log.Printf("[DEBUG] AuthenticationMiddleware: X-GPodder-Token validated for user: %s", username)
c.Set("userID", userID)
c.Set("username", username)
c.Next()
return
}
// If token doesn't match, authentication failed
log.Printf("[ERROR] AuthenticationMiddleware: Invalid X-GPodder-Token for user: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
// Standard basic auth handling
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
log.Printf("[ERROR] AuthenticationMiddleware: No Authorization header found")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Basic" {
log.Printf("[ERROR] AuthenticationMiddleware: Invalid Authorization header format")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
return
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
log.Printf("[ERROR] AuthenticationMiddleware: Failed to decode base64 credentials: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
return
}
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials format")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
return
}
authUsername := credentials[0]
password := credentials[1]
if strings.ToLower(username) != strings.ToLower(authUsername) {
log.Printf("[ERROR] AuthenticationMiddleware: Username mismatch - URL: %s, Auth: %s",
strings.ToLower(username), strings.ToLower(authUsername))
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
return
}
var userID int
var hashedPassword string
var podSyncType string
var gpodderToken sql.NullString
var query string
// Format query according to database type
if database.IsPostgreSQLDB() {
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
WHERE LOWER(Username) = LOWER($1)`
} else {
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM Users
WHERE LOWER(Username) = LOWER(?)`
}
err = database.QueryRow(query, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("[ERROR] AuthenticationMiddleware: User not found: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
} else {
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
username, podSyncType)
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
return
}
// Flag to track authentication success
authenticated := false
// Check if this is a gpodder token authentication
if gpodderToken.Valid && gpodderToken.String == password {
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with gpodder token: %s", username)
authenticated = true
}
// If token auth didn't succeed, try password authentication
if !authenticated && verifyPassword(password, hashedPassword) {
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with password: %s", username)
authenticated = true
}
// If authentication was successful, set context and continue
if authenticated {
c.Set("userID", userID)
c.Set("username", username)
c.Next()
return
}
// Authentication failed
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials for user: %s", username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
}
}
// verifyPassword verifies a password against a hash using Argon2
// This implementation matches the Pinepods authentication mechanism using alexedwards/argon2id
func verifyPassword(password, hashedPassword string) bool {
// Use the alexedwards/argon2id package to compare password and hash
match, err := argon2id.ComparePasswordAndHash(password, hashedPassword)
if err != nil {
// Log the error in a production environment
return false
}
return match
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,844 @@
package api
import (
"database/sql"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"pinepods/gpodder-api/internal/db"
"pinepods/gpodder-api/internal/models"
"github.com/gin-gonic/gin"
)
// getEpisodeActions handles GET /api/2/episodes/{username}.json
func getEpisodeActions(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, exists := c.Get("userID")
if !exists {
log.Printf("[ERROR] getEpisodeActions: userID not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Parse query parameters
sinceStr := c.Query("since")
podcastURL := c.Query("podcast")
deviceName := c.Query("device")
aggregated := c.Query("aggregated") == "true"
// Get device ID if provided
var deviceID *int
if deviceName != "" {
var deviceIDInt int
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
`
} else {
query = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
`
}
err := database.QueryRow(query, userID, deviceName).Scan(&deviceIDInt)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("[ERROR] getEpisodeActions: Error getting device: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
return
}
// Device not found is not fatal if querying by device
} else {
deviceID = &deviceIDInt
}
}
var since int64 = 0
if sinceStr != "" {
var err error
since, err = strconv.ParseInt(sinceStr, 10, 64)
if err != nil {
log.Printf("[ERROR] getEpisodeActions: Invalid since parameter: %s", sinceStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter: must be a Unix timestamp"})
return
}
}
// Get the latest timestamp for the response
var latestTimestamp int64
var timestampQuery string
if database.IsPostgreSQLDB() {
timestampQuery = `
SELECT COALESCE(MAX(Timestamp), EXTRACT(EPOCH FROM NOW())::bigint)
FROM "GpodderSyncEpisodeActions"
WHERE UserID = $1
`
} else {
timestampQuery = `
SELECT COALESCE(MAX(Timestamp), UNIX_TIMESTAMP())
FROM GpodderSyncEpisodeActions
WHERE UserID = ?
`
}
err := database.QueryRow(timestampQuery, userID).Scan(&latestTimestamp)
if err != nil {
log.Printf("[ERROR] getEpisodeActions: Error getting latest timestamp: %v", err)
latestTimestamp = time.Now().Unix() // Fallback to current time
}
// Performance optimization: Add limits and optimize query structure
const MAX_EPISODE_ACTIONS = 10000 // Reasonable limit for sync operations
// Log query performance info
log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v",
userID, since, deviceName, aggregated)
// Build query based on parameters with performance optimizations
var queryParts []string
if database.IsPostgreSQLDB() {
queryParts = []string{
"SELECT " +
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
"COALESCE(d.DeviceName, '') as DeviceName " +
"FROM \"GpodderSyncEpisodeActions\" e " +
"LEFT JOIN \"GpodderDevices\" d ON e.DeviceID = d.DeviceID " +
"WHERE e.UserID = $1",
}
} else {
queryParts = []string{
"SELECT " +
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
"COALESCE(d.DeviceName, '') as DeviceName " +
"FROM GpodderSyncEpisodeActions e " +
"LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID " +
"WHERE e.UserID = ?",
}
}
args := []interface{}{userID}
paramCount := 2
// For aggregated results, we need a more complex query
var query string
if aggregated {
if database.IsPostgreSQLDB() {
// Build conditions for the subquery
var conditions []string
if since > 0 {
conditions = append(conditions, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
args = append(args, since)
paramCount++
}
if podcastURL != "" {
conditions = append(conditions, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
args = append(args, podcastURL)
paramCount++
}
if deviceID != nil {
conditions = append(conditions, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
args = append(args, *deviceID)
paramCount++
}
conditionsStr := strings.Join(conditions, " ")
query = fmt.Sprintf(`
WITH latest_actions AS (
SELECT
e.PodcastURL,
e.EpisodeURL,
MAX(e.Timestamp) as max_timestamp
FROM "GpodderSyncEpisodeActions" e
WHERE e.UserID = $1
%s
GROUP BY e.PodcastURL, e.EpisodeURL
)
SELECT
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
d.DeviceName
FROM "GpodderSyncEpisodeActions" e
JOIN latest_actions la ON
e.PodcastURL = la.PodcastURL AND
e.EpisodeURL = la.EpisodeURL AND
e.Timestamp = la.max_timestamp
LEFT JOIN "GpodderDevices" d ON e.DeviceID = d.DeviceID
WHERE e.UserID = $1
ORDER BY e.Timestamp DESC
LIMIT %d
`, conditionsStr, MAX_EPISODE_ACTIONS)
} else {
// For MySQL, we need to use ? placeholders and rebuild the argument list
args = []interface{}{userID} // Reset args to just include userID for now
// Build conditions for the subquery
var conditions []string
if since > 0 {
conditions = append(conditions, "AND e.Timestamp > ?")
args = append(args, since)
}
if podcastURL != "" {
conditions = append(conditions, "AND e.PodcastURL = ?")
args = append(args, podcastURL)
}
if deviceID != nil {
conditions = append(conditions, "AND e.DeviceID = ?")
args = append(args, *deviceID)
}
conditionsStr := strings.Join(conditions, " ")
// Need to duplicate userID in args for the second part of the query
mysqlArgs := make([]interface{}, len(args))
copy(mysqlArgs, args)
args = append(args, mysqlArgs...)
query = fmt.Sprintf(`
WITH latest_actions AS (
SELECT
e.PodcastURL,
e.EpisodeURL,
MAX(e.Timestamp) as max_timestamp
FROM GpodderSyncEpisodeActions e
WHERE e.UserID = ?
%s
GROUP BY e.PodcastURL, e.EpisodeURL
)
SELECT
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
d.DeviceName
FROM GpodderSyncEpisodeActions e
JOIN latest_actions la ON
e.PodcastURL = la.PodcastURL AND
e.EpisodeURL = la.EpisodeURL AND
e.Timestamp = la.max_timestamp
LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID
WHERE e.UserID = ?
ORDER BY e.Timestamp DESC
LIMIT %d
`, conditionsStr, MAX_EPISODE_ACTIONS)
}
} else {
// Simple query with ORDER BY
if database.IsPostgreSQLDB() {
if since > 0 {
queryParts = append(queryParts, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
args = append(args, since)
paramCount++
}
if podcastURL != "" {
queryParts = append(queryParts, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
args = append(args, podcastURL)
paramCount++
}
if deviceID != nil {
queryParts = append(queryParts, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
args = append(args, *deviceID)
paramCount++
}
} else {
if since > 0 {
queryParts = append(queryParts, "AND e.Timestamp > ?")
args = append(args, since)
}
if podcastURL != "" {
queryParts = append(queryParts, "AND e.PodcastURL = ?")
args = append(args, podcastURL)
}
if deviceID != nil {
queryParts = append(queryParts, "AND e.DeviceID = ?")
args = append(args, *deviceID)
}
}
queryParts = append(queryParts, "ORDER BY e.Timestamp DESC")
// Add LIMIT for performance - prevents returning massive datasets
if database.IsPostgreSQLDB() {
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
} else {
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
}
query = strings.Join(queryParts, " ")
}
// Execute query with timing
startTime := time.Now()
rows, err := database.Query(query, args...)
queryDuration := time.Since(startTime)
if err != nil {
log.Printf("[ERROR] getEpisodeActions: Error querying episode actions (took %v): %v", queryDuration, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode actions"})
return
}
defer rows.Close()
log.Printf("[DEBUG] getEpisodeActions: Query executed in %v", queryDuration)
// Build response
actions := make([]models.EpisodeAction, 0)
for rows.Next() {
var action models.EpisodeAction
var deviceIDInt sql.NullInt64
var deviceName sql.NullString
var started sql.NullInt64
var position sql.NullInt64
var total sql.NullInt64
if err := rows.Scan(
&action.ActionID,
&action.UserID,
&deviceIDInt,
&action.Podcast,
&action.Episode,
&action.Action,
&action.Timestamp,
&started,
&position,
&total,
&deviceName,
); err != nil {
log.Printf("[ERROR] getEpisodeActions: Error scanning action row: %v", err)
continue
}
// Set optional fields if present
if deviceName.Valid {
action.Device = deviceName.String
}
if started.Valid {
startedInt := int(started.Int64)
action.Started = &startedInt
}
if position.Valid {
positionInt := int(position.Int64)
action.Position = &positionInt
}
if total.Valid {
totalInt := int(total.Int64)
action.Total = &totalInt
}
actions = append(actions, action)
}
if err = rows.Err(); err != nil {
log.Printf("[ERROR] getEpisodeActions: Error iterating rows: %v", err)
// Continue with what we've got so far
}
// Log performance results
totalDuration := time.Since(startTime)
log.Printf("[DEBUG] getEpisodeActions: Returning %d actions, total time: %v", len(actions), totalDuration)
// Return response in gpodder format
c.JSON(http.StatusOK, models.EpisodeActionsResponse{
Actions: actions,
Timestamp: latestTimestamp,
})
}
}
// uploadEpisodeActions handles POST /api/2/episodes/{username}.json
func uploadEpisodeActions(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, exists := c.Get("userID")
if !exists {
log.Printf("[ERROR] uploadEpisodeActions: userID not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Parse request - try both formats
var actions []models.EpisodeAction
// First try parsing as array directly
if err := c.ShouldBindJSON(&actions); err != nil {
// If that fails, try parsing as a wrapper object
var wrappedActions struct {
Actions []models.EpisodeAction `json:"actions"`
}
if err2 := c.ShouldBindJSON(&wrappedActions); err2 != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error parsing request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body format"})
return
}
actions = wrappedActions.Actions
}
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer func() {
if err != nil {
tx.Rollback()
return
}
}()
// Process actions
timestamp := time.Now().Unix()
updateURLs := make([][]string, 0)
for _, action := range actions {
// Validate action
if action.Podcast == "" || action.Episode == "" || action.Action == "" {
log.Printf("[WARNING] uploadEpisodeActions: Skipping invalid action: podcast=%s, episode=%s, action=%s",
action.Podcast, action.Episode, action.Action)
continue
}
// Clean URLs if needed
cleanPodcastURL, err := sanitizeURL(action.Podcast)
if err != nil {
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing podcast URL %s: %v", action.Podcast, err)
cleanPodcastURL = action.Podcast // Use original if sanitization fails
}
cleanEpisodeURL, err := sanitizeURL(action.Episode)
if err != nil {
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing episode URL %s: %v", action.Episode, err)
cleanEpisodeURL = action.Episode // Use original if sanitization fails
}
// Get or create device ID if provided
var deviceID sql.NullInt64
if action.Device != "" {
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2
`
} else {
query = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ?
`
}
err := tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
if err != nil {
if err == sql.ErrNoRows {
// Create the device if it doesn't exist
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP)
RETURNING DeviceID
`
err = tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
} else {
query = `
INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP)
`
result, err := tx.Exec(query, userID, action.Device, "other")
if err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
lastID, err := result.LastInsertId()
if err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error getting last insert ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
deviceID.Int64 = lastID
}
if err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
deviceID.Valid = true
} else {
log.Printf("[ERROR] uploadEpisodeActions: Error getting device ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
return
}
} else {
deviceID.Valid = true
}
}
// Parse timestamp from interface{} to int64
actionTimestamp := timestamp
if action.Timestamp != nil {
switch t := action.Timestamp.(type) {
case float64:
actionTimestamp = int64(t)
case int64:
actionTimestamp = t
case int:
actionTimestamp = int64(t)
case string:
// First try to parse as Unix timestamp
if ts, err := strconv.ParseInt(t, 10, 64); err == nil {
actionTimestamp = ts
} else {
// Try parsing as ISO date (2025-04-23T12:18:51)
if parsedTime, err := time.Parse(time.RFC3339, t); err == nil {
actionTimestamp = parsedTime.Unix()
log.Printf("[DEBUG] uploadEpisodeActions: Parsed ISO timestamp '%s' to Unix timestamp %d", t, actionTimestamp)
} else {
// Try some other common formats
formats := []string{
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
}
parsed := false
for _, format := range formats {
if parsedTime, err := time.Parse(format, t); err == nil {
actionTimestamp = parsedTime.Unix()
parsed = true
break
}
}
if !parsed {
log.Printf("[WARNING] uploadEpisodeActions: Could not parse timestamp '%s', using current time", t)
}
}
}
default:
log.Printf("[WARNING] uploadEpisodeActions: Unknown timestamp type, using current time")
}
}
// Insert action
var insertQuery string
if database.IsPostgreSQLDB() {
insertQuery = `
INSERT INTO "GpodderSyncEpisodeActions"
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
} else {
insertQuery = `
INSERT INTO GpodderSyncEpisodeActions
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
}
_, err = tx.Exec(insertQuery,
userID,
deviceID,
cleanPodcastURL,
cleanEpisodeURL,
action.Action,
actionTimestamp,
action.Started,
action.Position,
action.Total)
if err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error inserting episode action: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save episode action"})
return
}
// Add to updateURLs if URLs were cleaned
if cleanPodcastURL != action.Podcast {
updateURLs = append(updateURLs, []string{action.Podcast, cleanPodcastURL})
}
if cleanEpisodeURL != action.Episode {
updateURLs = append(updateURLs, []string{action.Episode, cleanEpisodeURL})
}
// For play action with position > 0, update episode status in Pinepods database
if action.Action == "play" && action.Position != nil && *action.Position > 0 {
// Try to find episode ID in Episodes table
var episodeID int
var findEpisodeQuery string
if database.IsPostgreSQLDB() {
findEpisodeQuery = `
SELECT e.EpisodeID
FROM "Episodes" e
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2 AND p.UserID = $3
`
} else {
findEpisodeQuery = `
SELECT e.EpisodeID
FROM Episodes e
JOIN Podcasts p ON e.PodcastID = p.PodcastID
WHERE p.FeedURL = ? AND e.EpisodeURL = ? AND p.UserID = ?
`
}
err := tx.QueryRow(findEpisodeQuery, cleanPodcastURL, cleanEpisodeURL, userID).Scan(&episodeID)
if err == nil { // Episode found
// Try to update existing history record
var updateHistoryQuery string
if database.IsPostgreSQLDB() {
updateHistoryQuery = `
UPDATE "UserEpisodeHistory"
SET ListenDuration = $1, ListenDate = $2
WHERE UserID = $3 AND EpisodeID = $4
`
} else {
updateHistoryQuery = `
UPDATE UserEpisodeHistory
SET ListenDuration = ?, ListenDate = ?
WHERE UserID = ? AND EpisodeID = ?
`
}
result, err := tx.Exec(updateHistoryQuery, action.Position, time.Unix(actionTimestamp, 0), userID, episodeID)
if err != nil {
log.Printf("[WARNING] uploadEpisodeActions: Error updating episode history: %v", err)
} else {
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
// No history exists, create it
var insertHistoryQuery string
if database.IsPostgreSQLDB() {
insertHistoryQuery = `
INSERT INTO "UserEpisodeHistory"
(UserID, EpisodeID, ListenDuration, ListenDate)
VALUES ($1, $2, $3, $4)
ON CONFLICT (UserID, EpisodeID) DO UPDATE
SET ListenDuration = $3, ListenDate = $4
`
} else {
insertHistoryQuery = `
INSERT INTO UserEpisodeHistory
(UserID, EpisodeID, ListenDuration, ListenDate)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
ListenDuration = VALUES(ListenDuration), ListenDate = VALUES(ListenDate)
`
}
_, err = tx.Exec(insertHistoryQuery, userID, episodeID, action.Position, time.Unix(actionTimestamp, 0))
if err != nil {
log.Printf("[WARNING] uploadEpisodeActions: Error creating episode history: %v", err)
}
}
}
}
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("[ERROR] uploadEpisodeActions: Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Return response
c.JSON(http.StatusOK, models.EpisodeActionResponse{
Timestamp: timestamp,
UpdateURLs: updateURLs,
})
}
}
// getFavoriteEpisodes handles GET /api/2/favorites/{username}.json
func getFavoriteEpisodes(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
// Query for favorite episodes
// Here we identify favorites by checking for episodes with the "is_favorite" setting
var query string
var rows *sql.Rows
var err error
if database.IsPostgreSQLDB() {
query = `
SELECT
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
p.PodcastName, p.FeedURL, e.EpisodePubDate
FROM "Episodes" e
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
JOIN "GpodderSyncSettings" s ON s.UserID = p.UserID
AND s.PodcastURL = p.FeedURL
AND s.EpisodeURL = e.EpisodeURL
WHERE s.UserID = $1
AND s.Scope = 'episode'
AND s.SettingKey = 'is_favorite'
AND s.SettingValue = 'true'
ORDER BY e.EpisodePubDate DESC
`
rows, err = database.Query(query, userID)
} else {
query = `
SELECT
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
p.PodcastName, p.FeedURL, e.EpisodePubDate
FROM Episodes e
JOIN Podcasts p ON e.PodcastID = p.PodcastID
JOIN GpodderSyncSettings s ON s.UserID = p.UserID
AND s.PodcastURL = p.FeedURL
AND s.EpisodeURL = e.EpisodeURL
WHERE s.UserID = ?
AND s.Scope = 'episode'
AND s.SettingKey = 'is_favorite'
AND s.SettingValue = 'true'
ORDER BY e.EpisodePubDate DESC
`
rows, err = database.Query(query, userID)
}
if err != nil {
log.Printf("Error querying favorite episodes: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get favorite episodes"})
return
}
defer rows.Close()
// Build response
favorites := make([]models.Episode, 0)
for rows.Next() {
var episode models.Episode
var pubDate time.Time
if err := rows.Scan(
&episode.Title,
&episode.URL,
&episode.Description,
&episode.Website, // Using EpisodeArtwork for Website for now
&episode.PodcastTitle,
&episode.PodcastURL,
&pubDate,
); err != nil {
log.Printf("Error scanning favorite episode: %v", err)
continue
}
// Format the publication date in ISO 8601
episode.Released = pubDate.Format(time.RFC3339)
// Set MygpoLink (just a placeholder for now)
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
favorites = append(favorites, episode)
}
c.JSON(http.StatusOK, favorites)
}
}
// getEpisodeData handles GET /api/2/data/episode.json
// getEpisodeData handles GET /api/2/data/episode.json
func getEpisodeData(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Parse query parameters
podcastURL := c.Query("podcast")
episodeURL := c.Query("url")
if podcastURL == "" || episodeURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Both podcast and url parameters are required"})
return
}
// Query for episode data
var episode models.Episode
var pubDate time.Time
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
p.PodcastName, p.FeedURL, e.EpisodePubDate
FROM "Episodes" e
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2
LIMIT 1
`
} else {
query = `
SELECT
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
p.PodcastName, p.FeedURL, e.EpisodePubDate
FROM Episodes e
JOIN Podcasts p ON e.PodcastID = p.PodcastID
WHERE p.FeedURL = ? AND e.EpisodeURL = ?
LIMIT 1
`
}
err := database.QueryRow(query, podcastURL, episodeURL).Scan(
&episode.Title,
&episode.URL,
&episode.Description,
&episode.Website, // Using EpisodeArtwork for Website for now
&episode.PodcastTitle,
&episode.PodcastURL,
&pubDate,
)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Episode not found"})
} else {
log.Printf("Error querying episode data: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode data"})
}
return
}
// Format the publication date in ISO 8601
episode.Released = pubDate.Format(time.RFC3339)
// Set MygpoLink (just a placeholder for now)
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
c.JSON(http.StatusOK, episode)
}
}

View File

@@ -0,0 +1,670 @@
package api
import (
"database/sql"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"pinepods/gpodder-api/internal/db"
"pinepods/gpodder-api/internal/models"
"github.com/gin-gonic/gin"
)
// getUserLists handles GET /api/2/lists/{username}.json
func getUserLists(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware (if authenticated)
userID, exists := c.Get("userID")
username := c.Param("username")
// If not authenticated, get user ID from username
if !exists {
var query string
if database.IsPostgreSQLDB() {
query = `SELECT UserID FROM "Users" WHERE Username = $1`
} else {
query = `SELECT UserID FROM Users WHERE Username = ?`
}
err := database.QueryRow(query, username).Scan(&userID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
} else {
log.Printf("Error getting user ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
}
return
}
}
// Query for user's podcast lists
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT ListID, Name, Title
FROM "GpodderSyncPodcastLists"
WHERE UserID = $1
`
} else {
query = `
SELECT ListID, Name, Title
FROM GpodderSyncPodcastLists
WHERE UserID = ?
`
}
rows, err := database.Query(query, userID)
if err != nil {
log.Printf("Error querying podcast lists: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast lists"})
return
}
defer rows.Close()
// Build response
lists := make([]models.PodcastList, 0)
for rows.Next() {
var list models.PodcastList
if err := rows.Scan(&list.ListID, &list.Name, &list.Title); err != nil {
log.Printf("Error scanning podcast list: %v", err)
continue
}
// Generate web URL
list.WebURL = fmt.Sprintf("/user/%s/lists/%s", username, list.Name)
lists = append(lists, list)
}
c.JSON(http.StatusOK, lists)
}
}
// createPodcastList handles POST /api/2/lists/{username}/create
func createPodcastList(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
username := c.Param("username")
// Get title from query parameter
title := c.Query("title")
if title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"})
return
}
// Get format from query parameter or default to json
format := c.Query("format")
if format == "" {
format = "json"
}
// Parse body for podcast URLs
var podcastURLs []string
switch format {
case "json":
if err := c.ShouldBindJSON(&podcastURLs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
case "txt":
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
// Split by newlines
lines := strings.Split(string(body), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
podcastURLs = append(podcastURLs, line)
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
return
}
// Generate name from title
name := generateNameFromTitle(title)
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Check if a list with this name already exists
var existingID int
var existsQuery string
if database.IsPostgreSQLDB() {
existsQuery = `
SELECT ListID FROM "GpodderSyncPodcastLists"
WHERE UserID = $1 AND Name = $2
`
} else {
existsQuery = `
SELECT ListID FROM GpodderSyncPodcastLists
WHERE UserID = ? AND Name = ?
`
}
err = tx.QueryRow(existsQuery, userID, name).Scan(&existingID)
if err == nil {
// List already exists
c.JSON(http.StatusConflict, gin.H{"error": "A podcast list with this name already exists"})
return
} else if err != sql.ErrNoRows {
log.Printf("Error checking list existence: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check list existence"})
return
}
// Create new list
var listID int
if database.IsPostgreSQLDB() {
err = tx.QueryRow(`
INSERT INTO "GpodderSyncPodcastLists" (UserID, Name, Title)
VALUES ($1, $2, $3)
RETURNING ListID
`, userID, name, title).Scan(&listID)
} else {
var result sql.Result
result, err = tx.Exec(`
INSERT INTO GpodderSyncPodcastLists (UserID, Name, Title)
VALUES (?, ?, ?)
`, userID, name, title)
if err != nil {
log.Printf("Error creating podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
return
}
lastID, err := result.LastInsertId()
if err != nil {
log.Printf("Error getting last insert ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
return
}
listID = int(lastID)
}
if err != nil {
log.Printf("Error creating podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
return
}
// Add podcasts to list
for _, url := range podcastURLs {
var insertQuery string
if database.IsPostgreSQLDB() {
insertQuery = `
INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL)
VALUES ($1, $2)
`
} else {
insertQuery = `
INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL)
VALUES (?, ?)
`
}
_, err = tx.Exec(insertQuery, listID, url)
if err != nil {
log.Printf("Error adding podcast to list: %v", err)
// Continue with other podcasts
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Return success with redirect location
c.Header("Location", fmt.Sprintf("/api/2/lists/%s/list/%s?format=%s", username, name, format))
c.Status(http.StatusSeeOther)
}
}
// getPodcastList handles GET /api/2/lists/{username}/list/{listname}
func getPodcastList(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get username and listname from URL
username := c.Param("username")
listName := c.Param("listname")
// Get format from query parameter or default to json
format := c.Query("format")
if format == "" {
format = "json"
}
// Get user ID from username
var userID int
var query string
if database.IsPostgreSQLDB() {
query = `SELECT UserID FROM "Users" WHERE Username = $1`
} else {
query = `SELECT UserID FROM Users WHERE Username = ?`
}
err := database.QueryRow(query, username).Scan(&userID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
} else {
log.Printf("Error getting user ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
}
return
}
// Get list info
var listID int
var title string
if database.IsPostgreSQLDB() {
query = `
SELECT ListID, Title FROM "GpodderSyncPodcastLists"
WHERE UserID = $1 AND Name = $2
`
} else {
query = `
SELECT ListID, Title FROM GpodderSyncPodcastLists
WHERE UserID = ? AND Name = ?
`
}
err = database.QueryRow(query, userID, listName).Scan(&listID, &title)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
} else {
log.Printf("Error getting podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
}
return
}
// Get podcasts in list
var rows *sql.Rows
if database.IsPostgreSQLDB() {
query = `
SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL
FROM "GpodderSyncPodcastListEntries" e
LEFT JOIN "Podcasts" p ON e.PodcastURL = p.FeedURL
WHERE e.ListID = $1
`
rows, err = database.Query(query, listID)
} else {
query = `
SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL
FROM GpodderSyncPodcastListEntries e
LEFT JOIN Podcasts p ON e.PodcastURL = p.FeedURL
WHERE e.ListID = ?
`
rows, err = database.Query(query, listID)
}
if err != nil {
log.Printf("Error querying podcasts in list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcasts in list"})
return
}
defer rows.Close()
// Build podcast list
podcasts := make([]models.Podcast, 0)
for rows.Next() {
var podcast models.Podcast
var podcastName, description, author, artworkURL, websiteURL sql.NullString
if err := rows.Scan(&podcast.URL, &podcastName, &description, &author, &artworkURL, &websiteURL); err != nil {
log.Printf("Error scanning podcast: %v", err)
continue
}
// Set values if present
if podcastName.Valid {
podcast.Title = podcastName.String
} else {
podcast.Title = podcast.URL
}
if description.Valid {
podcast.Description = description.String
}
if author.Valid {
podcast.Author = author.String
}
if artworkURL.Valid {
podcast.LogoURL = artworkURL.String
}
if websiteURL.Valid {
podcast.Website = websiteURL.String
}
// Add MygpoLink
podcast.MygpoLink = fmt.Sprintf("/podcast/%s", podcast.URL)
podcasts = append(podcasts, podcast)
}
// Return in requested format
switch format {
case "json":
c.JSON(http.StatusOK, podcasts)
case "txt":
// Plain text format - just URLs
var sb strings.Builder
for _, podcast := range podcasts {
sb.WriteString(podcast.URL)
sb.WriteString("\n")
}
c.String(http.StatusOK, sb.String())
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
}
}
}
// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname}
// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname}
func updatePodcastList(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
listName := c.Param("listname")
// Get format from query parameter or default to json
format := c.Query("format")
if format == "" {
format = "json"
}
// Parse body for podcast URLs
var podcastURLs []string
switch format {
case "json":
if err := c.ShouldBindJSON(&podcastURLs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
case "txt":
body, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
// Split by newlines
lines := strings.Split(string(body), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
podcastURLs = append(podcastURLs, line)
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
return
}
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Get list ID
var listID int
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT ListID FROM "GpodderSyncPodcastLists"
WHERE UserID = $1 AND Name = $2
`
} else {
query = `
SELECT ListID FROM GpodderSyncPodcastLists
WHERE UserID = ? AND Name = ?
`
}
err = tx.QueryRow(query, userID, listName).Scan(&listID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
} else {
log.Printf("Error getting podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
}
return
}
// Remove existing entries
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncPodcastListEntries"
WHERE ListID = $1
`
} else {
query = `
DELETE FROM GpodderSyncPodcastListEntries
WHERE ListID = ?
`
}
_, err = tx.Exec(query, listID)
if err != nil {
log.Printf("Error removing existing entries: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update podcast list"})
return
}
// Add new entries
for _, url := range podcastURLs {
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL)
VALUES ($1, $2)
`
} else {
query = `
INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL)
VALUES (?, ?)
`
}
_, err = tx.Exec(query, listID, url)
if err != nil {
log.Printf("Error adding podcast to list: %v", err)
// Continue with other podcasts
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Return success
c.Status(http.StatusNoContent)
}
}
// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname}
// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname}
func deletePodcastList(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
listName := c.Param("listname")
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Get list ID
var listID int
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT ListID FROM "GpodderSyncPodcastLists"
WHERE UserID = $1 AND Name = $2
`
} else {
query = `
SELECT ListID FROM GpodderSyncPodcastLists
WHERE UserID = ? AND Name = ?
`
}
err = tx.QueryRow(query, userID, listName).Scan(&listID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
} else {
log.Printf("Error getting podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
}
return
}
// Delete list entries first (cascade should handle this, but being explicit)
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncPodcastListEntries"
WHERE ListID = $1
`
} else {
query = `
DELETE FROM GpodderSyncPodcastListEntries
WHERE ListID = ?
`
}
_, err = tx.Exec(query, listID)
if err != nil {
log.Printf("Error deleting list entries: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"})
return
}
// Delete list
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncPodcastLists"
WHERE ListID = $1
`
} else {
query = `
DELETE FROM GpodderSyncPodcastLists
WHERE ListID = ?
`
}
_, err = tx.Exec(query, listID)
if err != nil {
log.Printf("Error deleting podcast list: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"})
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Return success
c.Status(http.StatusNoContent)
}
}
// Helper function to generate a URL-friendly name from a title
func generateNameFromTitle(title string) string {
// Convert to lowercase
name := strings.ToLower(title)
// Replace spaces with hyphens
name = strings.ReplaceAll(name, " ", "-")
// Remove special characters
re := regexp.MustCompile(`[^a-z0-9-]`)
name = re.ReplaceAllString(name, "")
// Ensure name is not empty
if name == "" {
name = "list"
}
return name
}

View File

@@ -0,0 +1,95 @@
package api
import (
"log"
"pinepods/gpodder-api/internal/db"
"github.com/gin-gonic/gin"
)
// Add or update in routes.go to ensure the Episode API routes are registered:
// RegisterRoutes registers all API routes
func RegisterRoutes(router *gin.RouterGroup, database *db.Database) {
// Authentication endpoints
log.Println("[INFO] Registering API routes...")
authGroup := router.Group("/auth/:username")
{
authGroup.POST("/login.json", handleLogin(database))
authGroup.POST("/logout.json", handleLogout(database))
}
// Device API
log.Println("[INFO] Registering device routes")
router.GET("/devices/:username.json", AuthenticationMiddleware(database), listDevices(database))
router.POST("/devices/:username/:deviceid", AuthenticationMiddleware(database), updateDeviceData(database))
router.GET("/updates/:username/:deviceid", AuthenticationMiddleware(database), getDeviceUpdates(database))
// Subscriptions API
subscriptionsGroup := router.Group("/subscriptions/:username")
subscriptionsGroup.Use(AuthenticationMiddleware(database))
{
subscriptionsGroup.GET("/:deviceid", getSubscriptions(database))
subscriptionsGroup.PUT("/:deviceid", updateSubscriptions(database))
subscriptionsGroup.POST("/:deviceid", uploadSubscriptionChanges(database))
// All subscriptions endpoint (since 2.11)
subscriptionsGroup.GET(".json", getAllSubscriptions(database))
}
// Episode Actions API - FIXED ROUTE PATTERN
log.Println("[INFO] Registering episode actions routes")
// Register directly on the router without a group
router.GET("/episodes/:username.json", AuthenticationMiddleware(database), getEpisodeActions(database))
router.POST("/episodes/:username.json", AuthenticationMiddleware(database), uploadEpisodeActions(database))
// Settings API
settingsGroup := router.Group("/settings/:username")
settingsGroup.Use(AuthenticationMiddleware(database))
{
settingsGroup.GET("/:scope.json", getSettings(database))
settingsGroup.POST("/:scope.json", saveSettings(database))
}
// Podcast Lists API
listsGroup := router.Group("/lists/:username")
{
listsGroup.GET(".json", getUserLists(database))
listsGroup.POST("/create", AuthenticationMiddleware(database), createPodcastList(database))
listGroup := listsGroup.Group("/list/:listname")
{
listGroup.GET("", getPodcastList(database))
listGroup.PUT("", AuthenticationMiddleware(database), updatePodcastList(database))
listGroup.DELETE("", AuthenticationMiddleware(database), deletePodcastList(database))
}
}
// Favorite Episodes API
router.GET("/favorites/:username.json", AuthenticationMiddleware(database), getFavoriteEpisodes(database))
// Device Synchronization API
syncGroup := router.Group("/sync-devices/:username")
syncGroup.Use(AuthenticationMiddleware(database))
{
syncGroup.GET(".json", getSyncStatus(database))
syncGroup.POST(".json", updateSyncStatus(database))
}
// Directory API (no auth required)
router.GET("/tags/:count.json", getTopTags(database))
router.GET("/tag/:tag/:count.json", getPodcastsForTag(database))
router.GET("/data/podcast.json", getPodcastData(database))
router.GET("/data/episode.json", getEpisodeData(database))
// Suggestions API (auth required)
router.GET("/suggestions/:count", AuthenticationMiddleware(database), getSuggestions(database))
}
// RegisterSimpleRoutes registers routes for the Simple API (v1)
func RegisterSimpleRoutes(router *gin.RouterGroup, database *db.Database) {
// Toplist
router.GET("/toplist/:number", getToplist(database))
// Search
router.GET("/search", podcastSearch(database))
// Subscriptions (Simple API)
router.GET("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), getSubscriptionsSimple(database))
router.PUT("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), updateSubscriptionsSimple(database))
}

View File

@@ -0,0 +1,991 @@
package api
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"unicode/utf8"
"pinepods/gpodder-api/internal/db"
"pinepods/gpodder-api/internal/models"
"github.com/gin-gonic/gin"
)
// Constants for settings
const (
MAX_SETTING_KEY_LENGTH = 255
MAX_SETTING_VALUE_LENGTH = 8192
MAX_SETTINGS_PER_REQUEST = 50
)
// Known settings that trigger behavior
var knownSettings = map[string]map[string]bool{
"account": {
"public_profile": true,
"store_user_agent": true,
"public_subscriptions": true,
"color_theme": true,
"default_subscribe_all": true,
},
"episode": {
"is_favorite": true,
"played": true,
"current_position": true,
},
"podcast": {
"public_subscription": true,
"auto_download": true,
"episode_sort": true,
},
"device": {
"auto_update": true,
"update_interval": true,
"wifi_only_downloads": true,
"max_episodes_per_feed": true,
},
}
// Validation interfaces and functions
// ValueValidator defines interface for validating settings values
type ValueValidator interface {
Validate(value interface{}) bool
}
// BooleanValidator validates boolean values
type BooleanValidator struct{}
func (v BooleanValidator) Validate(value interface{}) bool {
_, ok := value.(bool)
return ok
}
// IntValidator validates integer values
type IntValidator struct {
Min int
Max int
}
func (v IntValidator) Validate(value interface{}) bool {
num, ok := value.(float64) // JSON numbers are parsed as float64
if !ok {
return false
}
// Check if it's a whole number
if num != float64(int(num)) {
return false
}
// Check range if specified
intVal := int(num)
if v.Min != 0 || v.Max != 0 {
if intVal < v.Min || (v.Max != 0 && intVal > v.Max) {
return false
}
}
return true
}
// StringValidator validates string values
type StringValidator struct {
AllowedValues []string
MaxLength int
}
func (v StringValidator) Validate(value interface{}) bool {
str, ok := value.(string)
if !ok {
return false
}
// Check maximum length if specified
if v.MaxLength > 0 && utf8.RuneCountInString(str) > v.MaxLength {
return false
}
// Check allowed values if specified
if len(v.AllowedValues) > 0 {
for _, allowed := range v.AllowedValues {
if str == allowed {
return true
}
}
return false
}
return true
}
// validation rules for specific settings
var settingValidators = map[string]map[string]ValueValidator{
"account": {
"public_profile": BooleanValidator{},
"store_user_agent": BooleanValidator{},
"public_subscriptions": BooleanValidator{},
"default_subscribe_all": BooleanValidator{},
"color_theme": StringValidator{AllowedValues: []string{"light", "dark", "system"}, MaxLength: 10},
},
"episode": {
"is_favorite": BooleanValidator{},
"played": BooleanValidator{},
"current_position": IntValidator{Min: 0},
},
"podcast": {
"public_subscription": BooleanValidator{},
"auto_download": BooleanValidator{},
"episode_sort": StringValidator{AllowedValues: []string{"newest_first", "oldest_first", "title"}, MaxLength: 20},
},
"device": {
"auto_update": BooleanValidator{},
"update_interval": IntValidator{Min: 10, Max: 1440}, // 10 minutes to 24 hours
"wifi_only_downloads": BooleanValidator{},
"max_episodes_per_feed": IntValidator{Min: 1, Max: 1000},
},
}
// validateSettingValue validates a setting value based on its scope and key
func validateSettingValue(scope, key string, value interface{}) (bool, string) {
// Maximum setting value length check
jsonValue, err := json.Marshal(value)
if err != nil {
return false, "Failed to serialize setting value"
}
if len(jsonValue) > MAX_SETTING_VALUE_LENGTH {
return false, "Setting value exceeds maximum length"
}
// Check if we have a specific validator for this setting
if validators, ok := settingValidators[scope]; ok {
if validator, ok := validators[key]; ok {
if !validator.Validate(value) {
return false, "Setting value failed validation for the specified scope and key"
}
}
}
return true, ""
}
// getSettings handles GET /api/2/settings/{username}/{scope}.json
func getSettings(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context (set by AuthMiddleware)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Get scope from URL
scope := c.Param("scope")
if scope == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"})
return
}
// Validate scope
if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid scope. Valid values are: account, device, podcast, episode",
})
return
}
// Get optional query parameters
deviceID := c.Query("device")
podcastURL := c.Query("podcast")
episodeURL := c.Query("episode")
// Validate parameters based on scope
if scope == "device" && deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"})
return
}
if scope == "podcast" && podcastURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"})
return
}
if scope == "episode" && (podcastURL == "" || episodeURL == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"})
return
}
// Build query based on scope
var query string
var args []interface{}
switch scope {
case "account":
if database.IsPostgreSQLDB() {
query = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope)
} else {
query = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ?
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope)
}
case "device":
// Get device ID from name
var deviceIDInt int
var deviceQuery string
if database.IsPostgreSQLDB() {
deviceQuery = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
`
} else {
deviceQuery = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
`
}
err := database.QueryRow(deviceQuery, userID, deviceID).Scan(&deviceIDInt)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("Device not found: %s", deviceID)
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found or not active"})
} else {
log.Printf("Error getting device ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
}
return
}
if database.IsPostgreSQLDB() {
query = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, deviceIDInt)
} else {
query = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND DeviceID = ?
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, deviceIDInt)
}
case "podcast":
// Validate podcast URL
if !isValidURL(podcastURL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"})
return
}
if database.IsPostgreSQLDB() {
query = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, podcastURL)
} else {
query = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ?
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, podcastURL)
}
case "episode":
// Validate URLs
if !isValidURL(podcastURL) || !isValidURL(episodeURL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"})
return
}
if database.IsPostgreSQLDB() {
query = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4
AND DeviceID IS NULL
`
args = append(args, userID, scope, podcastURL, episodeURL)
} else {
query = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ?
AND DeviceID IS NULL
`
args = append(args, userID, scope, podcastURL, episodeURL)
}
}
// Query settings
rows, err := database.Query(query, args...)
if err != nil {
log.Printf("Error querying settings: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
return
}
defer rows.Close()
// Build settings map
settings := make(map[string]interface{})
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
log.Printf("Error scanning setting row: %v", err)
continue
}
// Try to unmarshal as JSON, fallback to string if not valid JSON
var jsonValue interface{}
if err := json.Unmarshal([]byte(value), &jsonValue); err != nil {
// Not valid JSON, use as string
settings[key] = value
} else {
// Valid JSON, use parsed value
settings[key] = jsonValue
}
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating setting rows: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
return
}
// Return settings
c.JSON(http.StatusOK, settings)
}
}
// isValidURL performs basic URL validation
func isValidURL(urlStr string) bool {
// Check if empty
if urlStr == "" {
return false
}
// Must start with http:// or https://
if !strings.HasPrefix(strings.ToLower(urlStr), "http://") &&
!strings.HasPrefix(strings.ToLower(urlStr), "https://") {
return false
}
// Basic length check
if len(urlStr) < 10 || len(urlStr) > 2048 {
return false
}
return true
}
// saveSettings handles POST /api/2/settings/{username}/{scope}.json
func saveSettings(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context (set by AuthMiddleware)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Get scope from URL
scope := c.Param("scope")
if scope == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"})
return
}
// Validate scope
if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid scope. Valid values are: account, device, podcast, episode",
})
return
}
// Get optional query parameters
deviceName := c.Query("device")
podcastURL := c.Query("podcast")
episodeURL := c.Query("episode")
// Validate parameters based on scope
if scope == "device" && deviceName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"})
return
}
if scope == "podcast" && podcastURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"})
return
}
if scope == "episode" && (podcastURL == "" || episodeURL == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"})
return
}
// Validate URLs
if scope == "podcast" && !isValidURL(podcastURL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"})
return
}
if scope == "episode" && (!isValidURL(podcastURL) || !isValidURL(episodeURL)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"})
return
}
// Parse request body
var req models.SettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("Error parsing request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'set' and 'remove' properties"})
return
}
// Validate request size
if len(req.Set) > MAX_SETTINGS_PER_REQUEST || len(req.Remove) > MAX_SETTINGS_PER_REQUEST {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Too many settings in request. Maximum allowed: %d", MAX_SETTINGS_PER_REQUEST),
})
return
}
// Process device ID if needed
var deviceID *int
if scope == "device" {
var deviceIDInt int
var deviceQuery string
if database.IsPostgreSQLDB() {
deviceQuery = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
`
} else {
deviceQuery = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
`
}
err := database.QueryRow(deviceQuery, userID, deviceName).Scan(&deviceIDInt)
if err != nil {
if err == sql.ErrNoRows {
// Create the device if it doesn't exist
if database.IsPostgreSQLDB() {
deviceQuery = `
INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
VALUES ($1, $2, 'other', true, $3)
RETURNING DeviceID
`
err = database.QueryRow(deviceQuery, userID, deviceName, time.Now()).Scan(&deviceIDInt)
} else {
deviceQuery = `
INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
VALUES (?, ?, 'other', true, ?)
`
result, err := database.Exec(deviceQuery, userID, deviceName, "other", time.Now())
if err != nil {
log.Printf("Error creating device: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
lastID, err := result.LastInsertId()
if err != nil {
log.Printf("Error getting last insert ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
deviceIDInt = int(lastID)
}
if err != nil {
log.Printf("Error creating device: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
return
}
} else {
log.Printf("Error getting device ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
return
}
}
deviceID = &deviceIDInt
}
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
defer func() {
if err != nil {
tx.Rollback()
return
}
}()
// Process settings to set
for key, value := range req.Set {
// Validate key
if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH {
log.Printf("Invalid setting key length: %s", key)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH),
})
return
}
// Allow only letters, numbers, underscores and hyphens
if !isValidSettingKey(key) {
log.Printf("Invalid setting key: %s", key)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens",
})
return
}
// Validate value
valid, errMsg := validateSettingValue(scope, key, value)
if !valid {
log.Printf("Invalid setting value for key %s: %s", key, errMsg)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid value for key '%s': %s", key, errMsg),
})
return
}
// Convert value to JSON string
jsonValue, err := json.Marshal(value)
if err != nil {
log.Printf("Error marshaling value to JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid value for key: " + key})
return
}
// Build query based on scope
var query string
var args []interface{}
switch scope {
case "account":
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncSettings" (UserID, Scope, SettingKey, SettingValue, LastUpdated)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (UserID, Scope, SettingKey)
WHERE DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
DO UPDATE SET SettingValue = $4, LastUpdated = $5
`
args = append(args, userID, scope, key, string(jsonValue), time.Now())
} else {
query = `
INSERT INTO GpodderSyncSettings (UserID, Scope, SettingKey, SettingValue, LastUpdated)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
`
args = append(args, userID, scope, key, string(jsonValue), time.Now())
}
case "device":
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncSettings" (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (UserID, Scope, SettingKey, DeviceID)
WHERE PodcastURL IS NULL AND EpisodeURL IS NULL
DO UPDATE SET SettingValue = $5, LastUpdated = $6
`
args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now())
} else {
query = `
INSERT INTO GpodderSyncSettings (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
`
args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now())
}
case "podcast":
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (UserID, Scope, SettingKey, PodcastURL)
WHERE DeviceID IS NULL AND EpisodeURL IS NULL
DO UPDATE SET SettingValue = $5, LastUpdated = $6
`
args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now())
} else {
query = `
INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
`
args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now())
}
case "episode":
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (UserID, Scope, SettingKey, PodcastURL, EpisodeURL)
WHERE DeviceID IS NULL
DO UPDATE SET SettingValue = $6, LastUpdated = $7
`
args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now())
} else {
query = `
INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
`
args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now())
}
}
// Execute query
_, err = tx.Exec(query, args...)
if err != nil {
log.Printf("Error setting value for key %s: %v", key, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
}
// Process settings to remove
for _, key := range req.Remove {
// Validate key
if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH {
log.Printf("Invalid setting key length: %s", key)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH),
})
return
}
// Allow only letters, numbers, underscores and hyphens
if !isValidSettingKey(key) {
log.Printf("Invalid setting key: %s", key)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens",
})
return
}
// Build query based on scope
var query string
var args []interface{}
switch scope {
case "account":
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND SettingKey = $3
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, key)
} else {
query = `
DELETE FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND SettingKey = ?
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, key)
}
case "device":
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3 AND SettingKey = $4
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, deviceID, key)
} else {
query = `
DELETE FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND DeviceID = ? AND SettingKey = ?
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, deviceID, key)
}
case "podcast":
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND SettingKey = $4
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, podcastURL, key)
} else {
query = `
DELETE FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND SettingKey = ?
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
args = append(args, userID, scope, podcastURL, key)
}
case "episode":
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4 AND SettingKey = $5
AND DeviceID IS NULL
`
args = append(args, userID, scope, podcastURL, episodeURL, key)
} else {
query = `
DELETE FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ? AND SettingKey = ?
AND DeviceID IS NULL
`
args = append(args, userID, scope, podcastURL, episodeURL, key)
}
}
// Execute query
_, err = tx.Exec(query, args...)
if err != nil {
log.Printf("Error removing key %s: %v", key, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
// Query all settings for the updated response
var queryAll string
var argsAll []interface{}
switch scope {
case "account":
if database.IsPostgreSQLDB() {
queryAll = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope)
} else {
queryAll = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ?
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope)
}
case "device":
if database.IsPostgreSQLDB() {
queryAll = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope, deviceID)
} else {
queryAll = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND DeviceID = ?
AND PodcastURL IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope, deviceID)
}
case "podcast":
if database.IsPostgreSQLDB() {
queryAll = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope, podcastURL)
} else {
queryAll = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ?
AND DeviceID IS NULL AND EpisodeURL IS NULL
`
argsAll = append(argsAll, userID, scope, podcastURL)
}
case "episode":
if database.IsPostgreSQLDB() {
queryAll = `
SELECT SettingKey, SettingValue
FROM "GpodderSyncSettings"
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4
AND DeviceID IS NULL
`
argsAll = append(argsAll, userID, scope, podcastURL, episodeURL)
} else {
queryAll = `
SELECT SettingKey, SettingValue
FROM GpodderSyncSettings
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ?
AND DeviceID IS NULL
`
argsAll = append(argsAll, userID, scope, podcastURL, episodeURL)
}
}
// Query all settings
rows, err := database.Query(queryAll, argsAll...)
if err != nil {
log.Printf("Error querying all settings: %v", err)
c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error
return
}
defer rows.Close()
// Build settings map
settings := make(map[string]interface{})
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
log.Printf("Error scanning setting row: %v", err)
continue
}
// Try to unmarshal as JSON, fallback to string if not valid JSON
var jsonValue interface{}
if err := json.Unmarshal([]byte(value), &jsonValue); err != nil {
// Not valid JSON, use as string
settings[key] = value
} else {
// Valid JSON, use parsed value
settings[key] = jsonValue
}
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating setting rows: %v", err)
c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error
return
}
// Return updated settings
c.JSON(http.StatusOK, settings)
}
}
// isValidSettingKey checks if the key contains only valid characters
func isValidSettingKey(key string) bool {
for _, r := range key {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' {
return false
}
}
return true
}
// toggleGpodderAPI is a Pinepods-specific extension to enable/disable the gpodder API for a user
func toggleGpodderAPI(database *db.PostgresDB) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from context (set by AuthMiddleware)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Parse request body
var req struct {
Enable bool `json:"enable"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Set Pod_Sync_Type based on enable flag
var podSyncType string
if req.Enable {
// Check if external gpodder sync is already enabled
var currentSyncType string
err := database.QueryRow(`
SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1
`, userID).Scan(&currentSyncType)
if err != nil {
log.Printf("Error getting current sync type: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
return
}
if currentSyncType == "external" {
podSyncType = "both"
} else {
podSyncType = "gpodder"
}
} else {
// Check if external gpodder sync is enabled
var currentSyncType string
err := database.QueryRow(`
SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1
`, userID).Scan(&currentSyncType)
if err != nil {
log.Printf("Error getting current sync type: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
return
}
if currentSyncType == "both" {
podSyncType = "external"
} else {
podSyncType = "None"
}
}
// Update user's Pod_Sync_Type
_, err := database.Exec(`
UPDATE "Users" SET Pod_Sync_Type = $1 WHERE UserID = $2
`, podSyncType, userID)
if err != nil {
log.Printf("Error updating Pod_Sync_Type: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
return
}
// Return success response
c.JSON(http.StatusOK, gin.H{
"enabled": req.Enable,
"sync_type": podSyncType,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
package api
import (
"database/sql"
"log"
"net/http"
"pinepods/gpodder-api/internal/db"
"pinepods/gpodder-api/internal/models"
"github.com/gin-gonic/gin"
)
// getSyncStatus handles GET /api/2/sync-devices/{username}.json
func getSyncStatus(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
// Query for device sync pairs
var query string
var rows *sql.Rows
var err error
if database.IsPostgreSQLDB() {
query = `
SELECT d1.DeviceName, d2.DeviceName
FROM "GpodderSyncDevicePairs" p
JOIN "GpodderDevices" d1 ON p.DeviceID1 = d1.DeviceID
JOIN "GpodderDevices" d2 ON p.DeviceID2 = d2.DeviceID
WHERE p.UserID = $1
`
rows, err = database.Query(query, userID)
} else {
query = `
SELECT d1.DeviceName, d2.DeviceName
FROM GpodderSyncDevicePairs p
JOIN GpodderDevices d1 ON p.DeviceID1 = d1.DeviceID
JOIN GpodderDevices d2 ON p.DeviceID2 = d2.DeviceID
WHERE p.UserID = ?
`
rows, err = database.Query(query, userID)
}
if err != nil {
log.Printf("Error querying device sync pairs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"})
return
}
// Build sync pairs
syncPairs := make([][]string, 0)
for rows.Next() {
var device1, device2 string
if err := rows.Scan(&device1, &device2); err != nil {
log.Printf("Error scanning device pair: %v", err)
continue
}
syncPairs = append(syncPairs, []string{device1, device2})
}
rows.Close()
// Query for devices not in any sync pair
if database.IsPostgreSQLDB() {
query = `
SELECT d.DeviceName
FROM "GpodderDevices" d
WHERE d.UserID = $1
AND d.DeviceID NOT IN (
SELECT DeviceID1 FROM "GpodderSyncDevicePairs" WHERE UserID = $1
UNION
SELECT DeviceID2 FROM "GpodderSyncDevicePairs" WHERE UserID = $1
)
`
rows, err = database.Query(query, userID)
} else {
query = `
SELECT d.DeviceName
FROM GpodderDevices d
WHERE d.UserID = ?
AND d.DeviceID NOT IN (
SELECT DeviceID1 FROM GpodderSyncDevicePairs WHERE UserID = ?
UNION
SELECT DeviceID2 FROM GpodderSyncDevicePairs WHERE UserID = ?
)
`
rows, err = database.Query(query, userID, userID, userID)
}
if err != nil {
log.Printf("Error querying non-synced devices: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"})
return
}
// Build non-synced devices list
nonSynced := make([]string, 0)
for rows.Next() {
var deviceName string
if err := rows.Scan(&deviceName); err != nil {
log.Printf("Error scanning non-synced device: %v", err)
continue
}
nonSynced = append(nonSynced, deviceName)
}
rows.Close()
// Return response
c.JSON(http.StatusOK, models.SyncDevicesResponse{
Synchronized: syncPairs,
NotSynchronized: nonSynced,
})
}
}
// updateSyncStatus handles POST /api/2/sync-devices/{username}.json
func updateSyncStatus(database *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user ID from middleware
userID, _ := c.Get("userID")
// Parse request
var req models.SyncDevicesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Begin transaction
tx, err := database.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Process synchronize pairs
for _, pair := range req.Synchronize {
if len(pair) != 2 {
continue
}
// Get device IDs
var device1ID, device2ID int
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2
`
err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID)
} else {
query = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ?
`
err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID)
}
if err != nil {
log.Printf("Error getting device ID for %s: %v", pair[0], err)
continue
}
if database.IsPostgreSQLDB() {
err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID)
} else {
err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID)
}
if err != nil {
log.Printf("Error getting device ID for %s: %v", pair[1], err)
continue
}
// Ensure device1ID < device2ID for consistency
if device1ID > device2ID {
device1ID, device2ID = device2ID, device1ID
}
// Insert sync pair if it doesn't exist
if database.IsPostgreSQLDB() {
query = `
INSERT INTO "GpodderSyncDevicePairs" (UserID, DeviceID1, DeviceID2)
VALUES ($1, $2, $3)
ON CONFLICT (UserID, DeviceID1, DeviceID2) DO NOTHING
`
_, err = tx.Exec(query, userID, device1ID, device2ID)
} else {
query = `
INSERT IGNORE INTO GpodderSyncDevicePairs (UserID, DeviceID1, DeviceID2)
VALUES (?, ?, ?)
`
_, err = tx.Exec(query, userID, device1ID, device2ID)
}
if err != nil {
log.Printf("Error creating sync pair: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create sync pair"})
return
}
}
// Process stop-synchronize devices
for _, deviceName := range req.StopSynchronize {
// Get device ID
var deviceID int
var query string
if database.IsPostgreSQLDB() {
query = `
SELECT DeviceID FROM "GpodderDevices"
WHERE UserID = $1 AND DeviceName = $2
`
err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID)
} else {
query = `
SELECT DeviceID FROM GpodderDevices
WHERE UserID = ? AND DeviceName = ?
`
err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID)
}
if err != nil {
log.Printf("Error getting device ID for %s: %v", deviceName, err)
continue
}
// Remove all sync pairs involving this device
if database.IsPostgreSQLDB() {
query = `
DELETE FROM "GpodderSyncDevicePairs"
WHERE UserID = $1 AND (DeviceID1 = $2 OR DeviceID2 = $2)
`
_, err = tx.Exec(query, userID, deviceID)
} else {
query = `
DELETE FROM GpodderSyncDevicePairs
WHERE UserID = ? AND (DeviceID1 = ? OR DeviceID2 = ?)
`
_, err = tx.Exec(query, userID, deviceID, deviceID)
}
if err != nil {
log.Printf("Error removing sync pairs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove sync pairs"})
return
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
return
}
// Return updated sync status by reusing the getSyncStatus handler
getSyncStatus(database)(c)
}
}

View File

@@ -0,0 +1,290 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"net/url"
"os"
"pinepods/gpodder-api/config"
"regexp"
"strings"
"time"
_ "github.com/go-sql-driver/mysql" // MySQL driver
_ "github.com/lib/pq" // PostgreSQL driver
)
// Database represents a database connection that can be either PostgreSQL or MySQL
type Database struct {
*sql.DB
Type string // "postgresql" or "mysql"
}
// NewDatabase creates a new database connection based on the DB_TYPE environment variable
func NewDatabase(cfg config.DatabaseConfig) (*Database, error) {
// Print connection details for debugging (hide password for security)
fmt.Printf("Connecting to %s database: host=%s port=%d user=%s dbname=%s\n",
cfg.Type, cfg.Host, cfg.Port, cfg.User, cfg.DBName)
var db *sql.DB
var err error
switch cfg.Type {
case "postgresql":
db, err = connectPostgreSQL(cfg)
case "mysql", "mariadb":
db, err = connectMySQL(cfg)
default:
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
}
if err != nil {
return nil, err
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
if strings.Contains(err.Error(), "password authentication failed") {
// Print environment variables (hide password)
fmt.Println("Password authentication failed. Environment variables:")
fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST"))
fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT"))
fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER"))
fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME"))
fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD")))
}
return nil, fmt.Errorf("failed to ping database: %w", err)
}
fmt.Println("Successfully connected to the database")
// Migrations are now handled by the Python migration system
// Skip Go migrations to avoid conflicts
log.Println("Skipping Go migrations - now handled by Python migration system")
return &Database{DB: db, Type: cfg.Type}, nil
}
// runMigrationsWithRetry - DISABLED: migrations now handled by Python system
// func runMigrationsWithRetry(db *sql.DB, dbType string) error {
// All migration logic has been moved to the Python migration system
// to ensure consistency and centralized management
// This function is kept for reference but is no longer used
// }
// connectPostgreSQL connects to a PostgreSQL database
func connectPostgreSQL(cfg config.DatabaseConfig) (*sql.DB, error) {
// Escape special characters in password
escapedPassword := url.QueryEscape(cfg.Password)
// Use a connection string without password for logging
logConnStr := fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode,
)
fmt.Printf("PostgreSQL connection string (without password): %s\n", logConnStr)
// Build the actual connection string with password
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
// Try standard connection string first
db, err := sql.Open("postgres", connStr)
if err != nil {
// Try URL format connection string
urlConnStr := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode,
)
fmt.Println("First connection attempt failed, trying URL format...")
db, err = sql.Open("postgres", urlConnStr)
}
return db, err
}
// Replace the existing connectMySQL function with this version
func connectMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
// Add needed parameters for MySQL authentication
connStr := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?parseTime=true&allowNativePasswords=true&multiStatements=true",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName,
)
fmt.Printf("Attempting MySQL connection to %s:%d as user '%s'\n",
cfg.Host, cfg.Port, cfg.User)
// Open the connection
db, err := sql.Open("mysql", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
}
// Configure connection pool
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
// Explicitly test the connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fmt.Println("Testing MySQL connection with ping...")
if err := db.PingContext(ctx); err != nil {
db.Close()
fmt.Printf("MySQL connection failed: %v\n", err)
return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
}
fmt.Println("MySQL connection successful!")
return db, nil
}
// Close closes the database connection
func (db *Database) Close() error {
return db.DB.Close()
}
// IsMySQLDB returns true if the database is MySQL/MariaDB
func (db *Database) IsMySQLDB() bool {
return db.Type == "mysql"
}
// IsPostgreSQLDB returns true if the database is PostgreSQL
func (db *Database) IsPostgreSQLDB() bool {
return db.Type == "postgresql"
}
// FormatQuery formats a query for the specific database type
func (db *Database) FormatQuery(query string) string {
if db.Type == "postgresql" {
return query // PostgreSQL queries already have correct format
}
// For MySQL:
result := query
// First, replace quoted table names
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys",
}
for _, table := range knownTables {
quoted := fmt.Sprintf("\"%s\"", table)
result = strings.ReplaceAll(result, quoted, table)
}
// Replace column quotes (double quotes to backticks)
re := regexp.MustCompile(`"([^"]+)"`)
result = re.ReplaceAllString(result, "`$1`")
// Then replace placeholders
for i := 10; i > 0; i-- {
old := fmt.Sprintf("$%d", i)
result = strings.ReplaceAll(result, old, "?")
}
return result
}
// Exec executes a query with the correct formatting for the database type
func (db *Database) Exec(query string, args ...interface{}) (sql.Result, error) {
formattedQuery := db.FormatQuery(query)
return db.DB.Exec(formattedQuery, args...)
}
// Query executes a query with the correct formatting for the database type
func (db *Database) Query(query string, args ...interface{}) (*sql.Rows, error) {
formattedQuery := db.FormatQuery(query)
return db.DB.Query(formattedQuery, args...)
}
// QueryRow executes a query with the correct formatting for the database type
func (db *Database) QueryRow(query string, args ...interface{}) *sql.Row {
formattedQuery := db.FormatQuery(query)
return db.DB.QueryRow(formattedQuery, args...)
}
// Begin starts a transaction with the correct formatting for the database type
func (db *Database) Begin() (*Transaction, error) {
tx, err := db.DB.Begin()
if err != nil {
return nil, err
}
return &Transaction{tx: tx, dbType: db.Type}, nil
}
// Transaction is a wrapper around sql.Tx that formats queries correctly
type Transaction struct {
tx *sql.Tx
dbType string
}
// Commit commits the transaction
func (tx *Transaction) Commit() error {
return tx.tx.Commit()
}
// Rollback rolls back the transaction
func (tx *Transaction) Rollback() error {
return tx.tx.Rollback()
}
// Exec executes a query in the transaction with correct formatting
func (tx *Transaction) Exec(query string, args ...interface{}) (sql.Result, error) {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.Exec(formattedQuery, args...)
}
// Query executes a query in the transaction with correct formatting
func (tx *Transaction) Query(query string, args ...interface{}) (*sql.Rows, error) {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.Query(formattedQuery, args...)
}
// QueryRow executes a query in the transaction with correct formatting
func (tx *Transaction) QueryRow(query string, args ...interface{}) *sql.Row {
formattedQuery := formatQuery(query, tx.dbType)
return tx.tx.QueryRow(formattedQuery, args...)
}
// Helper function to format queries
func formatQuery(query string, dbType string) string {
if dbType == "postgresql" {
return query
}
// For MySQL:
// Same logic as FormatQuery method
result := query
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys",
}
for _, table := range knownTables {
quoted := fmt.Sprintf("\"%s\"", table)
result = strings.ReplaceAll(result, quoted, table)
}
for i := 10; i > 0; i-- {
old := fmt.Sprintf("$%d", i)
result = strings.ReplaceAll(result, old, "?")
}
return result
}

View File

@@ -0,0 +1,176 @@
package db
import (
"fmt"
"strings"
)
// GetTableName returns the properly formatted table name based on DB type
func GetTableName(tableName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("\"%s\"", tableName)
}
return tableName
}
// GetPlaceholder returns the correct parameter placeholder based on DB type and index
func GetPlaceholder(index int, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("$%d", index)
}
return "?"
}
// GetPlaceholders returns a comma-separated list of placeholders
func GetPlaceholders(count int, dbType string) string {
placeholders := make([]string, count)
for i := 0; i < count; i++ {
if dbType == "postgresql" {
placeholders[i] = fmt.Sprintf("$%d", i+1)
} else {
placeholders[i] = "?"
}
}
return strings.Join(placeholders, ", ")
}
// GetColumnDefinition returns the appropriate column definition
func GetColumnDefinition(columnName, dataType string, dbType string) string {
// Handle special cases for different database types
switch dataType {
case "serial":
if dbType == "postgresql" {
return fmt.Sprintf("%s SERIAL", columnName)
}
return fmt.Sprintf("%s INT AUTO_INCREMENT", columnName)
case "boolean":
if dbType == "postgresql" {
return fmt.Sprintf("%s BOOLEAN", columnName)
}
return fmt.Sprintf("%s TINYINT(1)", columnName)
case "timestamp":
if dbType == "postgresql" {
return fmt.Sprintf("%s TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP", columnName)
default:
return fmt.Sprintf("%s %s", columnName, dataType)
}
}
// GetSerialPrimaryKey returns a serial primary key definition
func GetSerialPrimaryKey(columnName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("%s SERIAL PRIMARY KEY", columnName)
}
return fmt.Sprintf("%s INT AUTO_INCREMENT PRIMARY KEY", columnName)
}
// GetTimestampDefault returns a timestamp with default value
func GetTimestampDefault(columnName string, dbType string) string {
if dbType == "postgresql" {
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
// GetAutoUpdateTimestamp returns a timestamp that updates automatically
func GetAutoUpdateTimestamp(columnName string, dbType string) string {
if dbType == "postgresql" {
// PostgreSQL doesn't have a direct equivalent to MySQL's ON UPDATE
// In PostgreSQL this would typically be handled with a trigger
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP", columnName)
}
return fmt.Sprintf("%s TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", columnName)
}
// BuildInsertQuery builds an INSERT query with the correct placeholder syntax
func BuildInsertQuery(tableName string, columns []string, dbType string) string {
columnsStr := strings.Join(columns, ", ")
placeholders := GetPlaceholders(len(columns), dbType)
if dbType == "postgresql" {
return fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES (%s)", tableName, columnsStr, placeholders)
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, columnsStr, placeholders)
}
// BuildSelectQuery builds a SELECT query with the correct table name syntax
func BuildSelectQuery(tableName string, columns []string, whereClause string, dbType string) string {
columnsStr := strings.Join(columns, ", ")
if dbType == "postgresql" {
if whereClause != "" {
return fmt.Sprintf("SELECT %s FROM \"%s\" WHERE %s", columnsStr, tableName, whereClause)
}
return fmt.Sprintf("SELECT %s FROM \"%s\"", columnsStr, tableName)
}
if whereClause != "" {
return fmt.Sprintf("SELECT %s FROM %s WHERE %s", columnsStr, tableName, whereClause)
}
return fmt.Sprintf("SELECT %s FROM %s", columnsStr, tableName)
}
// BuildUpdateQuery builds an UPDATE query with the correct syntax
func BuildUpdateQuery(tableName string, setColumns []string, whereClause string, dbType string) string {
setClauses := make([]string, len(setColumns))
for i, col := range setColumns {
if dbType == "postgresql" {
setClauses[i] = fmt.Sprintf("%s = $%d", col, i+1)
} else {
setClauses[i] = fmt.Sprintf("%s = ?", col)
}
}
setClauseStr := strings.Join(setClauses, ", ")
if dbType == "postgresql" {
return fmt.Sprintf("UPDATE \"%s\" SET %s WHERE %s", tableName, setClauseStr, whereClause)
}
return fmt.Sprintf("UPDATE %s SET %s WHERE %s", tableName, setClauseStr, whereClause)
}
// RewriteQuery rewrites a PostgreSQL query to MySQL syntax
func RewriteQuery(query, dbType string) string {
if dbType == "postgresql" {
return query
}
// Replace placeholders
rewritten := query
// Replace placeholders first, starting from highest number to avoid conflicts
for i := 20; i > 0; i-- {
placeholder := fmt.Sprintf("$%d", i)
rewritten = strings.ReplaceAll(rewritten, placeholder, "?")
}
// Replace quoted table names
knownTables := []string{
"Users", "GpodderDevices", "GpodderSyncSettings",
"GpodderSyncSubscriptions", "GpodderSyncEpisodeActions",
"GpodderSyncPodcastLists", "GpodderSyncState", "GpodderSessions",
"GpodderSyncMigrations", "Podcasts", "Episodes", "SavedEpisodes",
"UserEpisodeHistory", "UserSettings", "APIKeys", "UserVideoHistory",
"SavedVideos", "DownloadedEpisodes", "DownloadedVideos", "EpisodeQueue",
}
for _, table := range knownTables {
quotedTable := fmt.Sprintf("\"%s\"", table)
rewritten = strings.ReplaceAll(rewritten, quotedTable, table)
}
// Handle RETURNING clause (MySQL doesn't support it)
returningIdx := strings.Index(strings.ToUpper(rewritten), "RETURNING")
if returningIdx > 0 {
rewritten = rewritten[:returningIdx]
}
return rewritten
}

View File

@@ -0,0 +1,538 @@
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
// Migration represents a database migration
type Migration struct {
Version int
Description string
PostgreSQLSQL string
MySQLSQL string
}
// MigrationRecord represents a record of an applied migration
type MigrationRecord struct {
Version int
Description string
AppliedAt time.Time
}
// EnsureMigrationsTable creates the migrations table if it doesn't exist
func EnsureMigrationsTable(db *sql.DB, dbType string) error {
log.Println("Creating GpodderSyncMigrations table if it doesn't exist...")
var query string
if dbType == "postgresql" {
query = `
CREATE TABLE IF NOT EXISTS "GpodderSyncMigrations" (
Version INT PRIMARY KEY,
Description TEXT NOT NULL,
AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
} else {
query = `
CREATE TABLE IF NOT EXISTS GpodderSyncMigrations (
Version INT PRIMARY KEY,
Description TEXT NOT NULL,
AppliedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
}
_, err := db.Exec(query)
if err != nil {
log.Printf("Error creating migrations table: %v", err)
return err
}
log.Println("GpodderSyncMigrations table is ready")
return nil
}
// GetAppliedMigrations returns a list of already applied migrations
func GetAppliedMigrations(db *sql.DB, dbType string) ([]MigrationRecord, error) {
log.Println("Checking previously applied migrations...")
var query string
if dbType == "postgresql" {
query = `
SELECT Version, Description, AppliedAt
FROM "GpodderSyncMigrations"
ORDER BY Version ASC
`
} else {
query = `
SELECT Version, Description, AppliedAt
FROM GpodderSyncMigrations
ORDER BY Version ASC
`
}
rows, err := db.Query(query)
if err != nil {
log.Printf("Error checking applied migrations: %v", err)
return nil, err
}
defer rows.Close()
var migrations []MigrationRecord
for rows.Next() {
var m MigrationRecord
if err := rows.Scan(&m.Version, &m.Description, &m.AppliedAt); err != nil {
log.Printf("Error scanning migration record: %v", err)
return nil, err
}
migrations = append(migrations, m)
}
if len(migrations) > 0 {
log.Printf("Found %d previously applied migrations", len(migrations))
} else {
log.Println("No previously applied migrations found")
}
return migrations, nil
}
// ApplyMigration applies a single migration
func ApplyMigration(db *sql.DB, migration Migration, dbType string) error {
log.Printf("Applying migration %d: %s", migration.Version, migration.Description)
// Select the appropriate SQL based on database type
var sql string
if dbType == "postgresql" {
sql = migration.PostgreSQLSQL
} else {
sql = migration.MySQLSQL
}
// Begin transaction
tx, err := db.Begin()
if err != nil {
log.Printf("Error beginning transaction for migration %d: %v", migration.Version, err)
return err
}
defer func() {
if err != nil {
log.Printf("Rolling back migration %d due to error", migration.Version)
tx.Rollback()
return
}
}()
// Execute the migration SQL
_, err = tx.Exec(sql)
if err != nil {
log.Printf("Failed to apply migration %d: %v", migration.Version, err)
return fmt.Errorf("failed to apply migration %d: %w", migration.Version, err)
}
// Record the migration
var insertQuery string
if dbType == "postgresql" {
insertQuery = `
INSERT INTO "GpodderSyncMigrations" (Version, Description)
VALUES ($1, $2)
`
} else {
insertQuery = `
INSERT INTO GpodderSyncMigrations (Version, Description)
VALUES (?, ?)
`
}
_, err = tx.Exec(insertQuery, migration.Version, migration.Description)
if err != nil {
log.Printf("Failed to record migration %d: %v", migration.Version, err)
return fmt.Errorf("failed to record migration %d: %w", migration.Version, err)
}
// Commit the transaction
err = tx.Commit()
if err != nil {
log.Printf("Failed to commit migration %d: %v", migration.Version, err)
return err
}
log.Printf("Successfully applied migration %d", migration.Version)
return nil
}
// checkRequiredTables verifies that required PinePods tables exist before running migrations
func checkRequiredTables(db *sql.DB, dbType string) error {
log.Println("Checking for required PinePods tables...")
requiredTables := []string{"Users", "GpodderDevices"}
for _, table := range requiredTables {
var query string
if dbType == "postgresql" {
query = `SELECT 1 FROM "` + table + `" LIMIT 1`
} else {
query = `SELECT 1 FROM ` + table + ` LIMIT 1`
}
_, err := db.Exec(query)
if err != nil {
log.Printf("Required table %s does not exist or is not accessible: %v", table, err)
return fmt.Errorf("required table %s does not exist - please ensure PinePods main migrations have run first", table)
}
log.Printf("Required table %s exists", table)
}
log.Println("All required tables found")
return nil
}
// RunMigrations runs all pending migrations
func RunMigrations(db *sql.DB, dbType string) error {
log.Println("Starting gpodder API migrations...")
// Check that required PinePods tables exist first
if err := checkRequiredTables(db, dbType); err != nil {
return fmt.Errorf("prerequisite check failed: %w", err)
}
// Ensure migrations table exists
if err := EnsureMigrationsTable(db, dbType); err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get applied migrations
appliedMigrations, err := GetAppliedMigrations(db, dbType)
if err != nil {
return fmt.Errorf("failed to get applied migrations: %w", err)
}
// Build a map of applied migration versions for quick lookup
appliedVersions := make(map[int]bool)
for _, m := range appliedMigrations {
appliedVersions[m.Version] = true
}
// Get all migrations
migrations := GetMigrations()
log.Printf("Found %d total migrations to check", len(migrations))
// Apply pending migrations
appliedCount := 0
for _, migration := range migrations {
if appliedVersions[migration.Version] {
// Migration already applied, skip
log.Printf("Migration %d already applied, skipping", migration.Version)
continue
}
log.Printf("Applying migration %d: %s", migration.Version, migration.Description)
if err := ApplyMigration(db, migration, dbType); err != nil {
return err
}
appliedCount++
}
if appliedCount > 0 {
log.Printf("Successfully applied %d new migrations", appliedCount)
} else {
log.Println("No new migrations to apply")
}
return nil
}
// GetMigrations returns all migrations with SQL variants for both database types
func GetMigrations() []Migration {
return []Migration{
{
Version: 1,
Description: "Initial schema creation",
PostgreSQLSQL: `
-- Device sync state for the API
CREATE TABLE IF NOT EXISTS "GpodderSyncDeviceState" (
DeviceStateID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
SubscriptionCount INT DEFAULT 0,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
-- Subscription changes
CREATE TABLE IF NOT EXISTS "GpodderSyncSubscriptions" (
SubscriptionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
PodcastURL TEXT NOT NULL,
Action VARCHAR(10) NOT NULL,
Timestamp BIGINT NOT NULL,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Episode actions
CREATE TABLE IF NOT EXISTS "GpodderSyncEpisodeActions" (
ActionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT,
PodcastURL TEXT NOT NULL,
EpisodeURL TEXT NOT NULL,
Action VARCHAR(20) NOT NULL,
Timestamp BIGINT NOT NULL,
Started INT,
Position INT,
Total INT,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Podcast lists
CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastLists" (
ListID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
Name VARCHAR(255) NOT NULL,
Title VARCHAR(255) NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
UNIQUE(UserID, Name)
);
-- Podcast list entries
CREATE TABLE IF NOT EXISTS "GpodderSyncPodcastListEntries" (
EntryID SERIAL PRIMARY KEY,
ListID INT NOT NULL,
PodcastURL TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ListID) REFERENCES "GpodderSyncPodcastLists"(ListID) ON DELETE CASCADE
);
-- Synchronization relationships between devices
CREATE TABLE IF NOT EXISTS "GpodderSyncDevicePairs" (
PairID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID1 INT NOT NULL,
DeviceID2 INT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID1) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID2) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID1, DeviceID2)
);
-- Settings storage
CREATE TABLE IF NOT EXISTS "GpodderSyncSettings" (
SettingID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
Scope VARCHAR(20) NOT NULL,
DeviceID INT,
PodcastURL TEXT,
EpisodeURL TEXT,
SettingKey VARCHAR(255) NOT NULL,
SettingValue TEXT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE
);
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_userid ON "GpodderSyncSubscriptions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_subscriptions_deviceid ON "GpodderSyncSubscriptions"(DeviceID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_episode_actions_userid ON "GpodderSyncEpisodeActions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sync_podcast_lists_userid ON "GpodderSyncPodcastLists"(UserID);
`,
MySQLSQL: `
-- Device sync state for the API
CREATE TABLE IF NOT EXISTS GpodderSyncDeviceState (
DeviceStateID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
SubscriptionCount INT DEFAULT 0,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
-- Subscription changes
CREATE TABLE IF NOT EXISTS GpodderSyncSubscriptions (
SubscriptionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
PodcastURL TEXT NOT NULL,
Action VARCHAR(10) NOT NULL,
Timestamp BIGINT NOT NULL,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Episode actions
CREATE TABLE IF NOT EXISTS GpodderSyncEpisodeActions (
ActionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT,
PodcastURL TEXT NOT NULL,
EpisodeURL TEXT NOT NULL,
Action VARCHAR(20) NOT NULL,
Timestamp BIGINT NOT NULL,
Started INT,
Position INT,
Total INT,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Podcast lists
CREATE TABLE IF NOT EXISTS GpodderSyncPodcastLists (
ListID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
Name VARCHAR(255) NOT NULL,
Title VARCHAR(255) NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
UNIQUE(UserID, Name)
);
-- Podcast list entries
CREATE TABLE IF NOT EXISTS GpodderSyncPodcastListEntries (
EntryID INT AUTO_INCREMENT PRIMARY KEY,
ListID INT NOT NULL,
PodcastURL TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ListID) REFERENCES GpodderSyncPodcastLists(ListID) ON DELETE CASCADE
);
-- Synchronization relationships between devices
CREATE TABLE IF NOT EXISTS GpodderSyncDevicePairs (
PairID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID1 INT NOT NULL,
DeviceID2 INT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID1) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID2) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID1, DeviceID2)
);
-- Settings storage
CREATE TABLE IF NOT EXISTS GpodderSyncSettings (
SettingID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
Scope VARCHAR(20) NOT NULL,
DeviceID INT,
PodcastURL TEXT,
EpisodeURL TEXT,
SettingKey VARCHAR(255) NOT NULL,
SettingValue TEXT,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
LastUpdated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE
);
-- Create indexes for faster queries
CREATE INDEX idx_gpodder_sync_subscriptions_userid ON GpodderSyncSubscriptions(UserID);
CREATE INDEX idx_gpodder_sync_subscriptions_deviceid ON GpodderSyncSubscriptions(DeviceID);
CREATE INDEX idx_gpodder_sync_episode_actions_userid ON GpodderSyncEpisodeActions(UserID);
CREATE INDEX idx_gpodder_sync_podcast_lists_userid ON GpodderSyncPodcastLists(UserID);
`,
},
{
Version: 2,
Description: "Add API version column to GpodderSyncSettings",
PostgreSQLSQL: `
ALTER TABLE "GpodderSyncSettings"
ADD COLUMN IF NOT EXISTS APIVersion VARCHAR(10) DEFAULT '2.0';
`,
MySQLSQL: `
-- Check if column exists first
SET @s = (SELECT IF(
COUNT(*) = 0,
'ALTER TABLE GpodderSyncSettings ADD COLUMN APIVersion VARCHAR(10) DEFAULT "2.0"',
'SELECT 1'
) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'GpodderSyncSettings'
AND COLUMN_NAME = 'APIVersion');
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
`,
},
{
Version: 3,
Description: "Create GpodderSessions table for API sessions",
PostgreSQLSQL: `
CREATE TABLE IF NOT EXISTS "GpodderSessions" (
SessionID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
SessionToken TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ExpiresAt TIMESTAMP NOT NULL,
LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UserAgent TEXT,
ClientIP TEXT,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
UNIQUE(SessionToken)
);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_token ON "GpodderSessions"(SessionToken);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_userid ON "GpodderSessions"(UserID);
CREATE INDEX IF NOT EXISTS idx_gpodder_sessions_expires ON "GpodderSessions"(ExpiresAt);
`,
MySQLSQL: `
CREATE TABLE IF NOT EXISTS GpodderSessions (
SessionID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
SessionToken TEXT NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ExpiresAt TIMESTAMP NOT NULL,
LastActive TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UserAgent TEXT,
ClientIP TEXT,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE
);
CREATE INDEX idx_gpodder_sessions_userid ON GpodderSessions(UserID);
CREATE INDEX idx_gpodder_sessions_expires ON GpodderSessions(ExpiresAt);
`,
},
{
Version: 4,
Description: "Add sync state table for tracking device sync status",
PostgreSQLSQL: `
CREATE TABLE IF NOT EXISTS "GpodderSyncState" (
SyncStateID SERIAL PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
LastTimestamp BIGINT DEFAULT 0,
LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES "Users"(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES "GpodderDevices"(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
CREATE INDEX IF NOT EXISTS idx_gpodder_syncstate_userid_deviceid ON "GpodderSyncState"(UserID, DeviceID);
`,
MySQLSQL: `
CREATE TABLE IF NOT EXISTS GpodderSyncState (
SyncStateID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT NOT NULL,
DeviceID INT NOT NULL,
LastTimestamp BIGINT DEFAULT 0,
LastSync TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES Users(UserID) ON DELETE CASCADE,
FOREIGN KEY (DeviceID) REFERENCES GpodderDevices(DeviceID) ON DELETE CASCADE,
UNIQUE(UserID, DeviceID)
);
CREATE INDEX idx_gpodder_syncstate_userid_deviceid ON GpodderSyncState(UserID, DeviceID);
`,
},
}
}

View File

@@ -0,0 +1,91 @@
package db
import (
"database/sql"
"fmt"
"net/url"
"os"
"pinepods/gpodder-api/config"
"strings"
_ "github.com/lib/pq"
)
// PostgresDB represents a connection to the PostgreSQL database
type PostgresDB struct {
*sql.DB
}
// NewPostgresDB creates a new PostgreSQL database connection
func NewPostgresDB(cfg config.DatabaseConfig) (*PostgresDB, error) {
// Print connection details for debugging (hide password for security)
fmt.Printf("Connecting to database: host=%s port=%d user=%s dbname=%s sslmode=%s\n",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode)
// Get password directly from environment to handle special characters
password := os.Getenv("DB_PASSWORD")
if password == "" {
// Fall back to config if env var is empty
password = cfg.Password
}
// Escape special characters in password
escapedPassword := url.QueryEscape(password)
// Use a connection string without password for logging
logConnStr := fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.DBName, cfg.SSLMode,
)
fmt.Printf("Connection string (without password): %s\n", logConnStr)
// Build the actual connection string with password
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, password, cfg.DBName, cfg.SSLMode,
)
// Try alternate connection string format if the first fails
db, err := sql.Open("postgres", connStr)
if err != nil {
// Try URL format connection string
urlConnStr := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s",
cfg.User, escapedPassword, cfg.Host, cfg.Port, cfg.DBName, cfg.SSLMode,
)
fmt.Println("First connection attempt failed, trying URL format...")
db, err = sql.Open("postgres", urlConnStr)
if err != nil {
return nil, fmt.Errorf("failed to open database connection: %w", err)
}
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
// Check if error contains password authentication failure
if strings.Contains(err.Error(), "password authentication failed") {
// Print environment variables (hide password)
fmt.Println("Password authentication failed. Environment variables:")
fmt.Printf("DB_HOST=%s\n", os.Getenv("DB_HOST"))
fmt.Printf("DB_PORT=%s\n", os.Getenv("DB_PORT"))
fmt.Printf("DB_USER=%s\n", os.Getenv("DB_USER"))
fmt.Printf("DB_NAME=%s\n", os.Getenv("DB_NAME"))
fmt.Printf("DB_PASSWORD=*** (length: %d)\n", len(os.Getenv("DB_PASSWORD")))
}
return nil, fmt.Errorf("failed to ping database: %w", err)
}
fmt.Println("Successfully connected to the database")
// Migrations are now handled by the Python migration system
// Skip Go migrations to avoid conflicts
fmt.Println("Skipping Go migrations - now handled by Python migration system")
return &PostgresDB{DB: db}, nil
}
// Close closes the database connection
func (db *PostgresDB) Close() error {
return db.DB.Close()
}

View File

@@ -0,0 +1,171 @@
package models
import (
"time"
)
// Device represents a user device
type Device struct {
ID int `json:"-"`
UserID int `json:"-"`
DeviceID string `json:"id"`
Caption string `json:"caption"`
Type string `json:"type"`
Subscriptions int `json:"subscriptions"`
CreatedAt time.Time `json:"-"`
LastUpdated time.Time `json:"-"`
}
// GpodderDevice represents a device from the GpodderDevices table
type GpodderDevice struct {
DeviceID int `json:"-"`
UserID int `json:"-"`
DeviceName string `json:"id"`
DeviceType string `json:"type"`
DeviceCaption string `json:"caption"`
IsDefault bool `json:"-"`
LastSync time.Time `json:"-"`
IsActive bool `json:"-"`
// Additional field for API responses
Subscriptions int `json:"subscriptions"`
}
// Subscription represents a podcast subscription
type Subscription struct {
SubscriptionID int `json:"-"`
UserID int `json:"-"`
DeviceID int `json:"-"`
PodcastURL string `json:"url"`
Action string `json:"-"`
Timestamp int64 `json:"-"`
}
// SubscriptionChange represents a change to subscriptions
type SubscriptionChange struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
}
// SubscriptionResponse represents a response to subscription change request
type SubscriptionResponse struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
Timestamp int64 `json:"timestamp"`
UpdateURLs [][]string `json:"update_urls"` // Removed omitempty to ensure field is always present
}
// EpisodeAction represents an action performed on an episode
// First, create a struct for the JSON request format
type EpisodeActionRequest struct {
Actions []EpisodeAction `json:"actions"`
}
// Then modify the EpisodeAction struct to use a flexible type for timestamp
type EpisodeAction struct {
ActionID int `json:"-"`
UserID int `json:"-"`
DeviceID int `json:"-"`
Podcast string `json:"podcast"`
Episode string `json:"episode"`
Device string `json:"device,omitempty"`
Action string `json:"action"`
Timestamp interface{} `json:"timestamp"` // Accept any type
Started *int `json:"started,omitempty"`
Position *int `json:"position,omitempty"`
Total *int `json:"total,omitempty"`
}
// EpisodeActionResponse represents a response to episode action upload
type EpisodeActionResponse struct {
Timestamp int64 `json:"timestamp"`
UpdateURLs [][]string `json:"update_urls"` // Removed omitempty
}
// EpisodeActionsResponse represents a response for episode actions retrieval
type EpisodeActionsResponse struct {
Actions []EpisodeAction `json:"actions"`
Timestamp int64 `json:"timestamp"`
}
// PodcastList represents a user's podcast list
type PodcastList struct {
ListID int `json:"-"`
UserID int `json:"-"`
Name string `json:"name"`
Title string `json:"title"`
CreatedAt time.Time `json:"-"`
WebURL string `json:"web"`
Podcasts []Podcast `json:"-"`
}
// Podcast represents a podcast
type Podcast struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Website string `json:"website,omitempty"`
Subscribers int `json:"subscribers,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
ScaledLogoURL string `json:"scaled_logo_url,omitempty"`
Author string `json:"author,omitempty"`
MygpoLink string `json:"mygpo_link,omitempty"`
}
// Episode represents a podcast episode
type Episode struct {
Title string `json:"title"`
URL string `json:"url"`
PodcastTitle string `json:"podcast_title"`
PodcastURL string `json:"podcast_url"`
Description string `json:"description"`
Website string `json:"website"`
Released string `json:"released"` // ISO 8601 format
MygpoLink string `json:"mygpo_link"`
}
// Setting represents a user setting
type Setting struct {
SettingID int `json:"-"`
UserID int `json:"-"`
Scope string `json:"-"`
DeviceID int `json:"-"`
PodcastURL string `json:"-"`
EpisodeURL string `json:"-"`
SettingKey string `json:"-"`
SettingValue string `json:"-"`
CreatedAt time.Time `json:"-"`
LastUpdated time.Time `json:"-"`
}
// SettingsRequest represents a settings update request
type SettingsRequest struct {
Set map[string]interface{} `json:"set"`
Remove []string `json:"remove"`
}
// Tag represents a tag
type Tag struct {
Title string `json:"title"`
Tag string `json:"tag"`
Usage int `json:"usage"`
}
// SyncDevicesResponse represents the sync status response
type SyncDevicesResponse struct {
Synchronized [][]string `json:"synchronized"`
NotSynchronized []string `json:"not-synchronized"`
}
// SyncDevicesRequest represents a sync status update request
type SyncDevicesRequest struct {
Synchronize [][]string `json:"synchronize"`
StopSynchronize []string `json:"stop-synchronize"`
}
// DeviceUpdateResponse represents a response to device updates request
type DeviceUpdateResponse struct {
Add []Podcast `json:"add"`
Remove []string `json:"remove"`
Updates []Episode `json:"updates"`
Timestamp int64 `json:"timestamp"`
}

View File

@@ -0,0 +1,163 @@
package utils
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/mmcdole/gofeed"
)
// PodcastValues represents metadata extracted from a podcast feed
type PodcastValues struct {
Title string `json:"title"`
ArtworkURL string `json:"artwork_url"`
Author string `json:"author"`
Categories string `json:"categories"`
Description string `json:"description"`
EpisodeCount int `json:"episode_count"`
FeedURL string `json:"feed_url"`
WebsiteURL string `json:"website_url"`
Explicit bool `json:"explicit"`
UserID int `json:"user_id"`
}
// GetPodcastValues fetches and parses a podcast feed
func GetPodcastValues(feedURL string, userID int, username string, password string) (*PodcastValues, error) {
log.Printf("[INFO] Fetching podcast data from feed: %s", feedURL)
// Create a feed parser with custom configuration
fp := gofeed.NewParser()
// Set a reasonable timeout to prevent hanging
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Parse the feed
feed, err := fp.ParseURLWithContext(feedURL, ctx)
if err != nil {
log.Printf("[ERROR] Failed to parse feed %s: %v", feedURL, err)
// Return minimal data even when failing
return &PodcastValues{
Title: feedURL,
Description: fmt.Sprintf("Podcast with feed: %s", feedURL),
FeedURL: feedURL,
UserID: userID,
EpisodeCount: 0,
}, err
}
// Initialize podcast values
podcastValues := &PodcastValues{
Title: feed.Title,
FeedURL: feedURL,
UserID: userID,
EpisodeCount: len(feed.Items),
}
// Extract basic data
if feed.Description != "" {
podcastValues.Description = feed.Description
}
if feed.Author != nil && feed.Author.Name != "" {
podcastValues.Author = feed.Author.Name
}
if feed.Link != "" {
podcastValues.WebsiteURL = feed.Link
}
// Extract artwork URL
if feed.Image != nil && feed.Image.URL != "" {
podcastValues.ArtworkURL = feed.Image.URL
}
// Process iTunes extensions if available
extensions := feed.Extensions
if extensions != nil {
if itunesExt, ok := extensions["itunes"]; ok {
// Check for iTunes author
if itunesAuthor, exists := itunesExt["author"]; exists && len(itunesAuthor) > 0 {
if podcastValues.Author == "" && itunesAuthor[0].Value != "" {
podcastValues.Author = itunesAuthor[0].Value
}
}
// Check for iTunes image
if itunesImage, exists := itunesExt["image"]; exists && len(itunesImage) > 0 {
if podcastValues.ArtworkURL == "" && itunesImage[0].Attrs["href"] != "" {
podcastValues.ArtworkURL = itunesImage[0].Attrs["href"]
}
}
// Check for explicit content
if itunesExplicit, exists := itunesExt["explicit"]; exists && len(itunesExplicit) > 0 {
explicitValue := strings.ToLower(itunesExplicit[0].Value)
podcastValues.Explicit = explicitValue == "yes" || explicitValue == "true"
}
// Check for categories
if itunesCategories, exists := itunesExt["category"]; exists && len(itunesCategories) > 0 {
categories := make(map[string]string)
for i, category := range itunesCategories {
if category.Attrs["text"] != "" {
categories[fmt.Sprintf("%d", i+1)] = category.Attrs["text"]
// A simplified approach for subcategories
// Many iTunes category extensions have nested category elements
// directly within them as attributes
if subCategoryText, hasSubCategory := category.Attrs["subcategory"]; hasSubCategory {
categories[fmt.Sprintf("%d.1", i+1)] = subCategoryText
}
}
}
// Serialize categories to JSON string if we found any
if len(categories) > 0 {
categoriesJSON, err := json.Marshal(categories)
if err == nil {
podcastValues.Categories = string(categoriesJSON)
} else {
log.Printf("[WARNING] Failed to serialize categories: %v", err)
podcastValues.Categories = "{}"
}
}
}
// Check for iTunes summary
if itunesSummary, exists := itunesExt["summary"]; exists && len(itunesSummary) > 0 {
if podcastValues.Description == "" && itunesSummary[0].Value != "" {
podcastValues.Description = itunesSummary[0].Value
}
}
}
}
// Fill in defaults for missing values
if podcastValues.Title == "" {
podcastValues.Title = feedURL
}
if podcastValues.Description == "" {
podcastValues.Description = fmt.Sprintf("Podcast feed: %s", feedURL)
}
if podcastValues.Author == "" {
podcastValues.Author = "Unknown Author"
}
if podcastValues.Categories == "" {
podcastValues.Categories = "{}"
}
log.Printf("[INFO] Successfully parsed podcast feed: %s, title: %s, episodes: %d",
feedURL, podcastValues.Title, podcastValues.EpisodeCount)
return podcastValues, nil
}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# /usr/local/bin/start-gpodder.sh
# This script is kept for backward compatibility, but shouldn't be needed
# as the gpodder-api is now managed by supervisord
# Check if the gpodder-api is already running under supervisor
if supervisorctl status gpodder_api | grep -q "RUNNING"; then
echo "gpodder-api is already running under supervisor, exiting"
exit 0
fi
# Start the gpodder-api only if it's not already managed by supervisor
echo "Starting gpodder-api (standalone mode) with PID logging"
nohup /usr/local/bin/gpodder-api > /var/log/gpodder-api.log 2>&1 &
PID=$!
echo "Started gpodder-api with PID $PID"
echo $PID > /var/run/gpodder-api.pid