added cargo files
This commit is contained in:
110
PinePods-0.8.2/gpodder-api/cmd/server/main.go
Normal file
110
PinePods-0.8.2/gpodder-api/cmd/server/main.go
Normal 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")
|
||||
}
|
||||
93
PinePods-0.8.2/gpodder-api/config/config.go
Normal file
93
PinePods-0.8.2/gpodder-api/config/config.go
Normal 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
|
||||
}
|
||||
46
PinePods-0.8.2/gpodder-api/go.mod
Normal file
46
PinePods-0.8.2/gpodder-api/go.mod
Normal 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
|
||||
)
|
||||
170
PinePods-0.8.2/gpodder-api/go.sum
Normal file
170
PinePods-0.8.2/gpodder-api/go.sum
Normal 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=
|
||||
850
PinePods-0.8.2/gpodder-api/internal/api/auth.go
Normal file
850
PinePods-0.8.2/gpodder-api/internal/api/auth.go
Normal 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
|
||||
}
|
||||
1039
PinePods-0.8.2/gpodder-api/internal/api/device.go
Normal file
1039
PinePods-0.8.2/gpodder-api/internal/api/device.go
Normal file
File diff suppressed because it is too large
Load Diff
1117
PinePods-0.8.2/gpodder-api/internal/api/directory.go
Normal file
1117
PinePods-0.8.2/gpodder-api/internal/api/directory.go
Normal file
File diff suppressed because it is too large
Load Diff
844
PinePods-0.8.2/gpodder-api/internal/api/episode.go
Normal file
844
PinePods-0.8.2/gpodder-api/internal/api/episode.go
Normal 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)
|
||||
}
|
||||
}
|
||||
670
PinePods-0.8.2/gpodder-api/internal/api/list.go
Normal file
670
PinePods-0.8.2/gpodder-api/internal/api/list.go
Normal 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
|
||||
}
|
||||
95
PinePods-0.8.2/gpodder-api/internal/api/routes.go
Normal file
95
PinePods-0.8.2/gpodder-api/internal/api/routes.go
Normal 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))
|
||||
}
|
||||
991
PinePods-0.8.2/gpodder-api/internal/api/settings.go
Normal file
991
PinePods-0.8.2/gpodder-api/internal/api/settings.go
Normal 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(¤tSyncType)
|
||||
|
||||
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(¤tSyncType)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
1878
PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go
Normal file
1878
PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go
Normal file
File diff suppressed because it is too large
Load Diff
267
PinePods-0.8.2/gpodder-api/internal/api/sync.go
Normal file
267
PinePods-0.8.2/gpodder-api/internal/api/sync.go
Normal 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)
|
||||
}
|
||||
}
|
||||
290
PinePods-0.8.2/gpodder-api/internal/db/database.go
Normal file
290
PinePods-0.8.2/gpodder-api/internal/db/database.go
Normal 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
|
||||
}
|
||||
176
PinePods-0.8.2/gpodder-api/internal/db/helpers.go
Normal file
176
PinePods-0.8.2/gpodder-api/internal/db/helpers.go
Normal 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
|
||||
}
|
||||
538
PinePods-0.8.2/gpodder-api/internal/db/migrations.go
Normal file
538
PinePods-0.8.2/gpodder-api/internal/db/migrations.go
Normal 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);
|
||||
`,
|
||||
},
|
||||
}
|
||||
}
|
||||
91
PinePods-0.8.2/gpodder-api/internal/db/postgres.go
Normal file
91
PinePods-0.8.2/gpodder-api/internal/db/postgres.go
Normal 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()
|
||||
}
|
||||
171
PinePods-0.8.2/gpodder-api/internal/models/models.go
Normal file
171
PinePods-0.8.2/gpodder-api/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
163
PinePods-0.8.2/gpodder-api/internal/utils/podcast_parser.go
Normal file
163
PinePods-0.8.2/gpodder-api/internal/utils/podcast_parser.go
Normal 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
|
||||
}
|
||||
18
PinePods-0.8.2/gpodder-api/start-gpodder.sh
Normal file
18
PinePods-0.8.2/gpodder-api/start-gpodder.sh
Normal 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
|
||||
Reference in New Issue
Block a user