added cargo files

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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