added cargo files
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user