added cargo files
This commit is contained in:
850
PinePods-0.8.2/gpodder-api/internal/api/auth.go
Normal file
850
PinePods-0.8.2/gpodder-api/internal/api/auth.go
Normal file
@@ -0,0 +1,850 @@
|
||||
// Package api provides the API endpoints for the gpodder API
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"github.com/fernet/fernet-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Define the parameters we use for Argon2id
|
||||
type argon2Params struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
parallelism uint8
|
||||
saltLength uint32
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
// AuthMiddleware creates a middleware for authentication
|
||||
func AuthMiddleware(db *db.PostgresDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Get the username from the URL parameters
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
log.Printf("[ERROR] AuthMiddleware: Username parameter is missing in path")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an internal API call via X-GPodder-Token
|
||||
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
|
||||
if gpodderTokenHeader != "" {
|
||||
|
||||
// Get user data
|
||||
var userID int
|
||||
var gpodderToken sql.NullString
|
||||
var podSyncType string
|
||||
|
||||
err := db.QueryRow(`
|
||||
SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
|
||||
WHERE LOWER(Username) = LOWER($1)
|
||||
`, username).Scan(&userID, &gpodderToken, &podSyncType)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if gpodder sync is enabled
|
||||
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
||||
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
||||
username, podSyncType)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// For internal calls with X-GPodder-Token header, validate token directly
|
||||
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// If token doesn't match, authentication failed
|
||||
log.Printf("[ERROR] AuthMiddleware: Invalid X-GPodder-Token for user: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// If no token header found, proceed with standard authentication
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Printf("[ERROR] AuthMiddleware: Authorization header is missing")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract credentials
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Basic" {
|
||||
log.Printf("[ERROR] AuthMiddleware: Invalid Authorization header format")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Decode credentials
|
||||
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] AuthMiddleware: Failed to decode base64 credentials: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract username and password
|
||||
credentials := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
log.Printf("[ERROR] AuthMiddleware: Invalid credentials format")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
authUsername := credentials[0]
|
||||
password := credentials[1]
|
||||
|
||||
// Check username match
|
||||
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
||||
log.Printf("[ERROR] AuthMiddleware: Username mismatch - URL: %s, Auth: %s",
|
||||
strings.ToLower(username), strings.ToLower(authUsername))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Query user data
|
||||
var userID int
|
||||
var hashedPassword string
|
||||
var podSyncType string
|
||||
var gpodderToken sql.NullString
|
||||
|
||||
err = db.QueryRow(`
|
||||
SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
|
||||
WHERE LOWER(Username) = LOWER($1)
|
||||
`, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("[ERROR] AuthMiddleware: User not found: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthMiddleware: Database error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if gpodder sync is enabled
|
||||
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
||||
log.Printf("[ERROR] AuthMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
||||
username, podSyncType)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Flag to track authentication success
|
||||
authenticated := false
|
||||
|
||||
// Check if this is a gpodder token authentication
|
||||
// Check if this is a gpodder token authentication
|
||||
if gpodderToken.Valid && (gpodderToken.String == password || gpodderToken.String == gpodderTokenHeader) {
|
||||
authenticated = true
|
||||
}
|
||||
|
||||
// If token auth didn't succeed, try password authentication
|
||||
if !authenticated && verifyPassword(password, hashedPassword) {
|
||||
authenticated = true
|
||||
}
|
||||
|
||||
// If authentication was successful, set context and continue
|
||||
if authenticated {
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication failed
|
||||
log.Printf("[ERROR] AuthMiddleware: Invalid credentials for user: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to decrypt token
|
||||
func decryptToken(encryptionKey []byte, encryptedToken string) (string, error) {
|
||||
// Ensure the encryptionKey is correctly formatted for fernet
|
||||
// Fernet requires a 32-byte key encoded in base64
|
||||
keyStr := base64.StdEncoding.EncodeToString(encryptionKey)
|
||||
|
||||
// Parse the key
|
||||
key, err := fernet.DecodeKey(keyStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the token
|
||||
token := []byte(encryptedToken)
|
||||
msg := fernet.VerifyAndDecrypt(token, 0, []*fernet.Key{key})
|
||||
if msg == nil {
|
||||
return "", fmt.Errorf("failed to decrypt token or token invalid")
|
||||
}
|
||||
|
||||
return string(msg), nil
|
||||
}
|
||||
|
||||
// generateSessionToken generates a random token for sessions
|
||||
func generateSessionToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// createSession creates a new session in the database
|
||||
func createSession(db *db.Database, userID int, userAgent, clientIP string) (string, time.Time, error) {
|
||||
// Generate a random session token
|
||||
token, err := generateSessionToken()
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration time (30 days from now)
|
||||
expires := time.Now().Add(30 * 24 * time.Hour)
|
||||
|
||||
// Insert session into database
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO "GpodderSessions" (UserID, SessionToken, ExpiresAt, UserAgent, ClientIP)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, userID, token, expires, userAgent, clientIP)
|
||||
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return token, expires, nil
|
||||
}
|
||||
|
||||
// validateSession validates a session token
|
||||
func validateSession(db *db.Database, token string) (int, bool, error) {
|
||||
var userID int
|
||||
var expires time.Time
|
||||
var query string
|
||||
|
||||
// Format query according to database type
|
||||
if db.IsPostgreSQLDB() {
|
||||
query = `SELECT UserID, ExpiresAt FROM "GpodderSessions" WHERE SessionToken = $1`
|
||||
} else {
|
||||
query = `SELECT UserID, ExpiresAt FROM GpodderSessions WHERE SessionToken = ?`
|
||||
}
|
||||
|
||||
err := db.QueryRow(query, token).Scan(&userID, &expires)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil // Session not found
|
||||
}
|
||||
return 0, false, fmt.Errorf("error validating session: %w", err)
|
||||
}
|
||||
|
||||
// Check if session has expired
|
||||
if time.Now().After(expires) {
|
||||
// Delete expired session
|
||||
if db.IsPostgreSQLDB() {
|
||||
query = `DELETE FROM "GpodderSessions" WHERE SessionToken = $1`
|
||||
} else {
|
||||
query = `DELETE FROM GpodderSessions WHERE SessionToken = ?`
|
||||
}
|
||||
|
||||
_, err = db.Exec(query, token)
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete expired session: %v", err)
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// Update last active time
|
||||
if db.IsPostgreSQLDB() {
|
||||
query = `UPDATE "GpodderSessions" SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = $1`
|
||||
} else {
|
||||
query = `UPDATE GpodderSessions SET LastActive = CURRENT_TIMESTAMP WHERE SessionToken = ?`
|
||||
}
|
||||
|
||||
_, err = db.Exec(query, token)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update session last active time: %v", err)
|
||||
}
|
||||
|
||||
return userID, true, nil
|
||||
}
|
||||
|
||||
// deleteSession removes a session from the database
|
||||
func deleteSession(db *db.Database, token string) error {
|
||||
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE SessionToken = $1`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteUserSessions removes all sessions for a user
|
||||
func deleteUserSessions(db *db.PostgresDB, userID int) error {
|
||||
_, err := db.Exec(`DELETE FROM "GpodderSessions" WHERE UserID = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user sessions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLogin enhanced with session management
|
||||
func handleLogin(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Use the AuthMiddleware to authenticate the user
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the Authorization header is in the correct format
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Basic" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the base64-encoded credentials
|
||||
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract username and password
|
||||
credentials := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsername := credentials[0]
|
||||
password := credentials[1]
|
||||
|
||||
// Verify that the username in the URL matches the one in the Authorization header
|
||||
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user exists and the password is correct
|
||||
var userID int
|
||||
var hashedPassword string
|
||||
var podSyncType string
|
||||
|
||||
// Make sure to use case-insensitive username lookup
|
||||
err = database.QueryRow(`
|
||||
SELECT UserID, Hashed_PW, Pod_Sync_Type FROM "Users" WHERE LOWER(Username) = LOWER($1)
|
||||
`, username).Scan(&userID, &hashedPassword, &podSyncType)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
} else {
|
||||
log.Printf("Database error during login: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if gpodder sync is enabled for this user
|
||||
if podSyncType != "gpodder" && podSyncType != "both" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password using Pinepods' Argon2 password method
|
||||
if !verifyPassword(password, hashedPassword) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
userAgent := c.Request.UserAgent()
|
||||
clientIP := c.ClientIP()
|
||||
sessionToken, expiresAt, err := createSession(database, userID, userAgent, clientIP)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to create session: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] handleLogin: Login successful for user: %s, created session token (first 8 chars): %s...",
|
||||
username, sessionToken[:8])
|
||||
|
||||
// Set session cookie
|
||||
c.SetCookie(
|
||||
"sessionid", // name
|
||||
sessionToken, // value
|
||||
int(30*24*time.Hour.Seconds()), // max age in seconds (30 days)
|
||||
"/", // path
|
||||
"", // domain (empty = current domain)
|
||||
c.Request.TLS != nil, // secure (HTTPS only)
|
||||
true, // httpOnly (not accessible via JavaScript)
|
||||
)
|
||||
|
||||
log.Printf("[DEBUG] handleLogin: Sending response with session expiry: %s",
|
||||
expiresAt.Format(time.RFC3339))
|
||||
// Return success with info
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "success",
|
||||
"userid": userID,
|
||||
"username": username,
|
||||
"session_expires": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogout enhanced with session management
|
||||
func handleLogout(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get username from URL
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the session cookie
|
||||
sessionToken, err := c.Cookie("sessionid")
|
||||
if err != nil || sessionToken == "" {
|
||||
// No session cookie, just return success (idempotent operation)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "logged out",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the session
|
||||
err = deleteSession(database, sessionToken)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting session: %v", err)
|
||||
// Continue anyway - we still want to invalidate the cookie
|
||||
}
|
||||
|
||||
// Clear the session cookie
|
||||
c.SetCookie(
|
||||
"sessionid", // name
|
||||
"", // value (empty = delete)
|
||||
-1, // max age (negative = delete)
|
||||
"/", // path
|
||||
"", // domain
|
||||
c.Request.TLS != nil, // secure
|
||||
true, // httpOnly
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "logged out",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SessionMiddleware checks if a user is logged in via session
|
||||
func SessionMiddleware(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
log.Printf("[DEBUG] SessionMiddleware processing request: %s %s",
|
||||
c.Request.Method, c.Request.URL.Path)
|
||||
|
||||
// First, try to get user from Authorization header for direct API access
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
log.Printf("[DEBUG] SessionMiddleware: Authorization header found, passing to next middleware")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// No Authorization header, check for session cookie
|
||||
sessionToken, err := c.Cookie("sessionid")
|
||||
if err != nil || sessionToken == "" {
|
||||
log.Printf("[ERROR] SessionMiddleware: No session cookie found: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] SessionMiddleware: Found session cookie, validating")
|
||||
|
||||
// Validate the session
|
||||
userID, valid, err := validateSession(database, sessionToken)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] SessionMiddleware: Error validating session: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Session error"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log.Printf("[ERROR] SessionMiddleware: Invalid or expired session")
|
||||
// Clear the invalid cookie
|
||||
c.SetCookie("sessionid", "", -1, "/", "", c.Request.TLS != nil, true)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] SessionMiddleware: Session valid for userID: %d", userID)
|
||||
|
||||
// Get the username for the user ID
|
||||
var username string
|
||||
err = database.QueryRow(`SELECT Username FROM "Users" WHERE UserID = $1`, userID).Scan(&username)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] SessionMiddleware: Error getting username for userID %d: %v",
|
||||
userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if gpodder sync is enabled for this user
|
||||
var podSyncType string
|
||||
err = database.QueryRow(`SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`, userID).Scan(&podSyncType)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] SessionMiddleware: Error checking sync type for userID %d: %v",
|
||||
userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "User data error"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if podSyncType != "gpodder" && podSyncType != "both" {
|
||||
log.Printf("[ERROR] SessionMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
||||
username, podSyncType)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Set the user information in the context
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
|
||||
// Check if the path username matches the session username
|
||||
pathUsername := c.Param("username")
|
||||
if pathUsername != "" && strings.ToLower(pathUsername) != strings.ToLower(username) {
|
||||
log.Printf("[ERROR] SessionMiddleware: Username mismatch - Path: %s, Session: %s",
|
||||
pathUsername, username)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Username mismatch"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] SessionMiddleware: Session authentication successful for user: %s", username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationMiddleware with GPodder token handling
|
||||
func AuthenticationMiddleware(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware processing request: %s %s",
|
||||
c.Request.Method, c.Request.URL.Path)
|
||||
|
||||
// Handle GPodder API standard .json suffix patterns
|
||||
if strings.HasSuffix(c.Request.URL.Path, ".json") {
|
||||
parts := strings.Split(c.Request.URL.Path, "/")
|
||||
var username string
|
||||
|
||||
// Handle /episodes/username.json pattern
|
||||
if strings.Contains(c.Request.URL.Path, "/episodes/") && len(parts) >= 3 {
|
||||
usernameWithExt := parts[len(parts)-1]
|
||||
username = strings.TrimSuffix(usernameWithExt, ".json")
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from episode actions URL", username)
|
||||
}
|
||||
|
||||
// Handle /devices/username.json pattern
|
||||
if strings.Contains(c.Request.URL.Path, "/devices/") {
|
||||
for i, part := range parts {
|
||||
if part == "devices" && i+1 < len(parts) {
|
||||
usernameWithExt := parts[i+1]
|
||||
username = strings.TrimSuffix(usernameWithExt, ".json")
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Extracted username '%s' from devices URL", username)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set username parameter if extracted
|
||||
if username != "" {
|
||||
c.Params = append(c.Params, gin.Param{Key: "username", Value: username})
|
||||
}
|
||||
}
|
||||
|
||||
// First try session auth
|
||||
sessionToken, err := c.Cookie("sessionid")
|
||||
if err == nil && sessionToken != "" {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Found session cookie, validating")
|
||||
|
||||
userID, valid, err := validateSession(database, sessionToken)
|
||||
if err == nil && valid {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Session valid for userID: %d", userID)
|
||||
|
||||
var username string
|
||||
var query string
|
||||
|
||||
// Format query according to database type
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT Username FROM "Users" WHERE UserID = $1`
|
||||
} else {
|
||||
query = `SELECT Username FROM Users WHERE UserID = ?`
|
||||
}
|
||||
|
||||
err = database.QueryRow(query, userID).Scan(&username)
|
||||
if err == nil {
|
||||
var podSyncType string
|
||||
|
||||
// Format query according to database type
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1`
|
||||
} else {
|
||||
query = `SELECT Pod_Sync_Type FROM Users WHERE UserID = ?`
|
||||
}
|
||||
|
||||
err = database.QueryRow(query, userID).Scan(&podSyncType)
|
||||
|
||||
if err == nil && (podSyncType == "gpodder" || podSyncType == "both") {
|
||||
// Check if the path username matches the session username
|
||||
pathUsername := c.Param("username")
|
||||
if pathUsername == "" || strings.ToLower(pathUsername) == strings.ToLower(username) {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Session auth successful for user: %s",
|
||||
username)
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Session username mismatch - Path: %s, Session: %s",
|
||||
pathUsername, username)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder not enabled for user: %s", username)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Could not get username for userID %d: %v",
|
||||
userID, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Invalid session: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: No session cookie, falling back to basic auth")
|
||||
}
|
||||
|
||||
// Try basic auth if session auth failed
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Attempting basic auth")
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Username parameter is missing in path")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an internal API call via X-GPodder-Token
|
||||
gpodderTokenHeader := c.GetHeader("X-GPodder-Token")
|
||||
if gpodderTokenHeader != "" {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: Found X-GPodder-Token header")
|
||||
|
||||
// Get user data
|
||||
var userID int
|
||||
var gpodderToken sql.NullString
|
||||
var podSyncType string
|
||||
var query string
|
||||
|
||||
// Format query according to database type
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM "Users"
|
||||
WHERE LOWER(Username) = LOWER($1)`
|
||||
} else {
|
||||
query = `SELECT UserID, GpodderToken, Pod_Sync_Type FROM Users
|
||||
WHERE LOWER(Username) = LOWER(?)`
|
||||
}
|
||||
|
||||
err := database.QueryRow(query, username).Scan(&userID, &gpodderToken, &podSyncType)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if gpodder sync is enabled
|
||||
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
||||
username, podSyncType)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
return
|
||||
}
|
||||
|
||||
// For internal calls with X-GPodder-Token header, validate token directly
|
||||
if gpodderToken.Valid && gpodderToken.String == gpodderTokenHeader {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: X-GPodder-Token validated for user: %s", username)
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// If token doesn't match, authentication failed
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Invalid X-GPodder-Token for user: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Standard basic auth handling
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: No Authorization header found")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Basic" {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Invalid Authorization header format")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
||||
return
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Failed to decode base64 credentials: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
credentials := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(credentials) != 2 {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials format")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials format"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsername := credentials[0]
|
||||
password := credentials[1]
|
||||
|
||||
if strings.ToLower(username) != strings.ToLower(authUsername) {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Username mismatch - URL: %s, Auth: %s",
|
||||
strings.ToLower(username), strings.ToLower(authUsername))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
var userID int
|
||||
var hashedPassword string
|
||||
var podSyncType string
|
||||
var gpodderToken sql.NullString
|
||||
var query string
|
||||
|
||||
// Format query according to database type
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM "Users"
|
||||
WHERE LOWER(Username) = LOWER($1)`
|
||||
} else {
|
||||
query = `SELECT UserID, Hashed_PW, Pod_Sync_Type, GpodderToken FROM Users
|
||||
WHERE LOWER(Username) = LOWER(?)`
|
||||
}
|
||||
|
||||
err = database.QueryRow(query, username).Scan(&userID, &hashedPassword, &podSyncType, &gpodderToken)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: User not found: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
} else {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Database error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if podSyncType != "gpodder" && podSyncType != "both" && podSyncType != "external" {
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Gpodder API not enabled for user: %s (sync type: %s)",
|
||||
username, podSyncType)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Gpodder API not enabled for this user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Flag to track authentication success
|
||||
authenticated := false
|
||||
|
||||
// Check if this is a gpodder token authentication
|
||||
if gpodderToken.Valid && gpodderToken.String == password {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with gpodder token: %s", username)
|
||||
authenticated = true
|
||||
}
|
||||
|
||||
// If token auth didn't succeed, try password authentication
|
||||
if !authenticated && verifyPassword(password, hashedPassword) {
|
||||
log.Printf("[DEBUG] AuthenticationMiddleware: User authenticated with password: %s", username)
|
||||
authenticated = true
|
||||
}
|
||||
|
||||
// If authentication was successful, set context and continue
|
||||
if authenticated {
|
||||
c.Set("userID", userID)
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication failed
|
||||
log.Printf("[ERROR] AuthenticationMiddleware: Invalid credentials for user: %s", username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||
}
|
||||
}
|
||||
|
||||
// verifyPassword verifies a password against a hash using Argon2
|
||||
// This implementation matches the Pinepods authentication mechanism using alexedwards/argon2id
|
||||
func verifyPassword(password, hashedPassword string) bool {
|
||||
// Use the alexedwards/argon2id package to compare password and hash
|
||||
match, err := argon2id.ComparePasswordAndHash(password, hashedPassword)
|
||||
if err != nil {
|
||||
// Log the error in a production environment
|
||||
return false
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
1039
PinePods-0.8.2/gpodder-api/internal/api/device.go
Normal file
1039
PinePods-0.8.2/gpodder-api/internal/api/device.go
Normal file
File diff suppressed because it is too large
Load Diff
1117
PinePods-0.8.2/gpodder-api/internal/api/directory.go
Normal file
1117
PinePods-0.8.2/gpodder-api/internal/api/directory.go
Normal file
File diff suppressed because it is too large
Load Diff
844
PinePods-0.8.2/gpodder-api/internal/api/episode.go
Normal file
844
PinePods-0.8.2/gpodder-api/internal/api/episode.go
Normal file
@@ -0,0 +1,844 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
"pinepods/gpodder-api/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getEpisodeActions handles GET /api/2/episodes/{username}.json
|
||||
func getEpisodeActions(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Get user ID from middleware
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
log.Printf("[ERROR] getEpisodeActions: userID not found in context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
sinceStr := c.Query("since")
|
||||
podcastURL := c.Query("podcast")
|
||||
deviceName := c.Query("device")
|
||||
aggregated := c.Query("aggregated") == "true"
|
||||
|
||||
// Get device ID if provided
|
||||
var deviceID *int
|
||||
if deviceName != "" {
|
||||
var deviceIDInt int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
|
||||
`
|
||||
}
|
||||
|
||||
err := database.QueryRow(query, userID, deviceName).Scan(&deviceIDInt)
|
||||
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Printf("[ERROR] getEpisodeActions: Error getting device: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
||||
return
|
||||
}
|
||||
// Device not found is not fatal if querying by device
|
||||
} else {
|
||||
deviceID = &deviceIDInt
|
||||
}
|
||||
}
|
||||
|
||||
var since int64 = 0
|
||||
if sinceStr != "" {
|
||||
var err error
|
||||
since, err = strconv.ParseInt(sinceStr, 10, 64)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] getEpisodeActions: Invalid since parameter: %s", sinceStr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter: must be a Unix timestamp"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the latest timestamp for the response
|
||||
var latestTimestamp int64
|
||||
var timestampQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
timestampQuery = `
|
||||
SELECT COALESCE(MAX(Timestamp), EXTRACT(EPOCH FROM NOW())::bigint)
|
||||
FROM "GpodderSyncEpisodeActions"
|
||||
WHERE UserID = $1
|
||||
`
|
||||
} else {
|
||||
timestampQuery = `
|
||||
SELECT COALESCE(MAX(Timestamp), UNIX_TIMESTAMP())
|
||||
FROM GpodderSyncEpisodeActions
|
||||
WHERE UserID = ?
|
||||
`
|
||||
}
|
||||
|
||||
err := database.QueryRow(timestampQuery, userID).Scan(&latestTimestamp)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] getEpisodeActions: Error getting latest timestamp: %v", err)
|
||||
latestTimestamp = time.Now().Unix() // Fallback to current time
|
||||
}
|
||||
|
||||
// Performance optimization: Add limits and optimize query structure
|
||||
const MAX_EPISODE_ACTIONS = 10000 // Reasonable limit for sync operations
|
||||
|
||||
// Log query performance info
|
||||
log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v",
|
||||
userID, since, deviceName, aggregated)
|
||||
|
||||
// Build query based on parameters with performance optimizations
|
||||
var queryParts []string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryParts = []string{
|
||||
"SELECT " +
|
||||
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
|
||||
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
|
||||
"COALESCE(d.DeviceName, '') as DeviceName " +
|
||||
"FROM \"GpodderSyncEpisodeActions\" e " +
|
||||
"LEFT JOIN \"GpodderDevices\" d ON e.DeviceID = d.DeviceID " +
|
||||
"WHERE e.UserID = $1",
|
||||
}
|
||||
} else {
|
||||
queryParts = []string{
|
||||
"SELECT " +
|
||||
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
|
||||
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
|
||||
"COALESCE(d.DeviceName, '') as DeviceName " +
|
||||
"FROM GpodderSyncEpisodeActions e " +
|
||||
"LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID " +
|
||||
"WHERE e.UserID = ?",
|
||||
}
|
||||
}
|
||||
|
||||
args := []interface{}{userID}
|
||||
paramCount := 2
|
||||
|
||||
// For aggregated results, we need a more complex query
|
||||
var query string
|
||||
if aggregated {
|
||||
if database.IsPostgreSQLDB() {
|
||||
// Build conditions for the subquery
|
||||
var conditions []string
|
||||
|
||||
if since > 0 {
|
||||
conditions = append(conditions, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
|
||||
args = append(args, since)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if podcastURL != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
|
||||
args = append(args, podcastURL)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if deviceID != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
|
||||
args = append(args, *deviceID)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
conditionsStr := strings.Join(conditions, " ")
|
||||
|
||||
query = fmt.Sprintf(`
|
||||
WITH latest_actions AS (
|
||||
SELECT
|
||||
e.PodcastURL,
|
||||
e.EpisodeURL,
|
||||
MAX(e.Timestamp) as max_timestamp
|
||||
FROM "GpodderSyncEpisodeActions" e
|
||||
WHERE e.UserID = $1
|
||||
%s
|
||||
GROUP BY e.PodcastURL, e.EpisodeURL
|
||||
)
|
||||
SELECT
|
||||
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
|
||||
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
|
||||
d.DeviceName
|
||||
FROM "GpodderSyncEpisodeActions" e
|
||||
JOIN latest_actions la ON
|
||||
e.PodcastURL = la.PodcastURL AND
|
||||
e.EpisodeURL = la.EpisodeURL AND
|
||||
e.Timestamp = la.max_timestamp
|
||||
LEFT JOIN "GpodderDevices" d ON e.DeviceID = d.DeviceID
|
||||
WHERE e.UserID = $1
|
||||
ORDER BY e.Timestamp DESC
|
||||
LIMIT %d
|
||||
`, conditionsStr, MAX_EPISODE_ACTIONS)
|
||||
} else {
|
||||
// For MySQL, we need to use ? placeholders and rebuild the argument list
|
||||
args = []interface{}{userID} // Reset args to just include userID for now
|
||||
|
||||
// Build conditions for the subquery
|
||||
var conditions []string
|
||||
|
||||
if since > 0 {
|
||||
conditions = append(conditions, "AND e.Timestamp > ?")
|
||||
args = append(args, since)
|
||||
}
|
||||
|
||||
if podcastURL != "" {
|
||||
conditions = append(conditions, "AND e.PodcastURL = ?")
|
||||
args = append(args, podcastURL)
|
||||
}
|
||||
|
||||
if deviceID != nil {
|
||||
conditions = append(conditions, "AND e.DeviceID = ?")
|
||||
args = append(args, *deviceID)
|
||||
}
|
||||
|
||||
conditionsStr := strings.Join(conditions, " ")
|
||||
|
||||
// Need to duplicate userID in args for the second part of the query
|
||||
mysqlArgs := make([]interface{}, len(args))
|
||||
copy(mysqlArgs, args)
|
||||
args = append(args, mysqlArgs...)
|
||||
|
||||
query = fmt.Sprintf(`
|
||||
WITH latest_actions AS (
|
||||
SELECT
|
||||
e.PodcastURL,
|
||||
e.EpisodeURL,
|
||||
MAX(e.Timestamp) as max_timestamp
|
||||
FROM GpodderSyncEpisodeActions e
|
||||
WHERE e.UserID = ?
|
||||
%s
|
||||
GROUP BY e.PodcastURL, e.EpisodeURL
|
||||
)
|
||||
SELECT
|
||||
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
|
||||
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
|
||||
d.DeviceName
|
||||
FROM GpodderSyncEpisodeActions e
|
||||
JOIN latest_actions la ON
|
||||
e.PodcastURL = la.PodcastURL AND
|
||||
e.EpisodeURL = la.EpisodeURL AND
|
||||
e.Timestamp = la.max_timestamp
|
||||
LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID
|
||||
WHERE e.UserID = ?
|
||||
ORDER BY e.Timestamp DESC
|
||||
LIMIT %d
|
||||
`, conditionsStr, MAX_EPISODE_ACTIONS)
|
||||
}
|
||||
} else {
|
||||
// Simple query with ORDER BY
|
||||
if database.IsPostgreSQLDB() {
|
||||
if since > 0 {
|
||||
queryParts = append(queryParts, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
|
||||
args = append(args, since)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if podcastURL != "" {
|
||||
queryParts = append(queryParts, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
|
||||
args = append(args, podcastURL)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if deviceID != nil {
|
||||
queryParts = append(queryParts, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
|
||||
args = append(args, *deviceID)
|
||||
paramCount++
|
||||
}
|
||||
} else {
|
||||
if since > 0 {
|
||||
queryParts = append(queryParts, "AND e.Timestamp > ?")
|
||||
args = append(args, since)
|
||||
}
|
||||
|
||||
if podcastURL != "" {
|
||||
queryParts = append(queryParts, "AND e.PodcastURL = ?")
|
||||
args = append(args, podcastURL)
|
||||
}
|
||||
|
||||
if deviceID != nil {
|
||||
queryParts = append(queryParts, "AND e.DeviceID = ?")
|
||||
args = append(args, *deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
queryParts = append(queryParts, "ORDER BY e.Timestamp DESC")
|
||||
|
||||
// Add LIMIT for performance - prevents returning massive datasets
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
|
||||
} else {
|
||||
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
|
||||
}
|
||||
|
||||
query = strings.Join(queryParts, " ")
|
||||
}
|
||||
|
||||
// Execute query with timing
|
||||
startTime := time.Now()
|
||||
rows, err := database.Query(query, args...)
|
||||
queryDuration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] getEpisodeActions: Error querying episode actions (took %v): %v", queryDuration, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode actions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
log.Printf("[DEBUG] getEpisodeActions: Query executed in %v", queryDuration)
|
||||
|
||||
// Build response
|
||||
actions := make([]models.EpisodeAction, 0)
|
||||
for rows.Next() {
|
||||
var action models.EpisodeAction
|
||||
var deviceIDInt sql.NullInt64
|
||||
var deviceName sql.NullString
|
||||
var started sql.NullInt64
|
||||
var position sql.NullInt64
|
||||
var total sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&action.ActionID,
|
||||
&action.UserID,
|
||||
&deviceIDInt,
|
||||
&action.Podcast,
|
||||
&action.Episode,
|
||||
&action.Action,
|
||||
&action.Timestamp,
|
||||
&started,
|
||||
&position,
|
||||
&total,
|
||||
&deviceName,
|
||||
); err != nil {
|
||||
log.Printf("[ERROR] getEpisodeActions: Error scanning action row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set optional fields if present
|
||||
if deviceName.Valid {
|
||||
action.Device = deviceName.String
|
||||
}
|
||||
|
||||
if started.Valid {
|
||||
startedInt := int(started.Int64)
|
||||
action.Started = &startedInt
|
||||
}
|
||||
|
||||
if position.Valid {
|
||||
positionInt := int(position.Int64)
|
||||
action.Position = &positionInt
|
||||
}
|
||||
|
||||
if total.Valid {
|
||||
totalInt := int(total.Int64)
|
||||
action.Total = &totalInt
|
||||
}
|
||||
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("[ERROR] getEpisodeActions: Error iterating rows: %v", err)
|
||||
// Continue with what we've got so far
|
||||
}
|
||||
|
||||
// Log performance results
|
||||
totalDuration := time.Since(startTime)
|
||||
log.Printf("[DEBUG] getEpisodeActions: Returning %d actions, total time: %v", len(actions), totalDuration)
|
||||
|
||||
// Return response in gpodder format
|
||||
c.JSON(http.StatusOK, models.EpisodeActionsResponse{
|
||||
Actions: actions,
|
||||
Timestamp: latestTimestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// uploadEpisodeActions handles POST /api/2/episodes/{username}.json
|
||||
func uploadEpisodeActions(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Get user ID from middleware
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: userID not found in context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request - try both formats
|
||||
var actions []models.EpisodeAction
|
||||
|
||||
// First try parsing as array directly
|
||||
if err := c.ShouldBindJSON(&actions); err != nil {
|
||||
// If that fails, try parsing as a wrapper object
|
||||
var wrappedActions struct {
|
||||
Actions []models.EpisodeAction `json:"actions"`
|
||||
}
|
||||
if err2 := c.ShouldBindJSON(&wrappedActions); err2 != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error parsing request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body format"})
|
||||
return
|
||||
}
|
||||
actions = wrappedActions.Actions
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Process actions
|
||||
timestamp := time.Now().Unix()
|
||||
updateURLs := make([][]string, 0)
|
||||
|
||||
for _, action := range actions {
|
||||
// Validate action
|
||||
if action.Podcast == "" || action.Episode == "" || action.Action == "" {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Skipping invalid action: podcast=%s, episode=%s, action=%s",
|
||||
action.Podcast, action.Episode, action.Action)
|
||||
continue
|
||||
}
|
||||
|
||||
// Clean URLs if needed
|
||||
cleanPodcastURL, err := sanitizeURL(action.Podcast)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing podcast URL %s: %v", action.Podcast, err)
|
||||
cleanPodcastURL = action.Podcast // Use original if sanitization fails
|
||||
}
|
||||
|
||||
cleanEpisodeURL, err := sanitizeURL(action.Episode)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing episode URL %s: %v", action.Episode, err)
|
||||
cleanEpisodeURL = action.Episode // Use original if sanitization fails
|
||||
}
|
||||
|
||||
// Get or create device ID if provided
|
||||
var deviceID sql.NullInt64
|
||||
if action.Device != "" {
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ?
|
||||
`
|
||||
}
|
||||
|
||||
err := tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// Create the device if it doesn't exist
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
||||
VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP)
|
||||
RETURNING DeviceID
|
||||
`
|
||||
err = tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
||||
VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP)
|
||||
`
|
||||
result, err := tx.Exec(query, userID, action.Device, "other")
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
|
||||
lastID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error getting last insert ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
|
||||
deviceID.Int64 = lastID
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
deviceID.Valid = true
|
||||
} else {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error getting device ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
deviceID.Valid = true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse timestamp from interface{} to int64
|
||||
actionTimestamp := timestamp
|
||||
if action.Timestamp != nil {
|
||||
switch t := action.Timestamp.(type) {
|
||||
case float64:
|
||||
actionTimestamp = int64(t)
|
||||
case int64:
|
||||
actionTimestamp = t
|
||||
case int:
|
||||
actionTimestamp = int64(t)
|
||||
case string:
|
||||
// First try to parse as Unix timestamp
|
||||
if ts, err := strconv.ParseInt(t, 10, 64); err == nil {
|
||||
actionTimestamp = ts
|
||||
} else {
|
||||
// Try parsing as ISO date (2025-04-23T12:18:51)
|
||||
if parsedTime, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
actionTimestamp = parsedTime.Unix()
|
||||
log.Printf("[DEBUG] uploadEpisodeActions: Parsed ISO timestamp '%s' to Unix timestamp %d", t, actionTimestamp)
|
||||
} else {
|
||||
// Try some other common formats
|
||||
formats := []string{
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
parsed := false
|
||||
for _, format := range formats {
|
||||
if parsedTime, err := time.Parse(format, t); err == nil {
|
||||
actionTimestamp = parsedTime.Unix()
|
||||
parsed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !parsed {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Could not parse timestamp '%s', using current time", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Unknown timestamp type, using current time")
|
||||
}
|
||||
}
|
||||
|
||||
// Insert action
|
||||
var insertQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
insertQuery = `
|
||||
INSERT INTO "GpodderSyncEpisodeActions"
|
||||
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
} else {
|
||||
insertQuery = `
|
||||
INSERT INTO GpodderSyncEpisodeActions
|
||||
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(insertQuery,
|
||||
userID,
|
||||
deviceID,
|
||||
cleanPodcastURL,
|
||||
cleanEpisodeURL,
|
||||
action.Action,
|
||||
actionTimestamp,
|
||||
action.Started,
|
||||
action.Position,
|
||||
action.Total)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error inserting episode action: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save episode action"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add to updateURLs if URLs were cleaned
|
||||
if cleanPodcastURL != action.Podcast {
|
||||
updateURLs = append(updateURLs, []string{action.Podcast, cleanPodcastURL})
|
||||
}
|
||||
if cleanEpisodeURL != action.Episode {
|
||||
updateURLs = append(updateURLs, []string{action.Episode, cleanEpisodeURL})
|
||||
}
|
||||
|
||||
// For play action with position > 0, update episode status in Pinepods database
|
||||
if action.Action == "play" && action.Position != nil && *action.Position > 0 {
|
||||
// Try to find episode ID in Episodes table
|
||||
var episodeID int
|
||||
var findEpisodeQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
findEpisodeQuery = `
|
||||
SELECT e.EpisodeID
|
||||
FROM "Episodes" e
|
||||
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
||||
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2 AND p.UserID = $3
|
||||
`
|
||||
} else {
|
||||
findEpisodeQuery = `
|
||||
SELECT e.EpisodeID
|
||||
FROM Episodes e
|
||||
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
||||
WHERE p.FeedURL = ? AND e.EpisodeURL = ? AND p.UserID = ?
|
||||
`
|
||||
}
|
||||
|
||||
err := tx.QueryRow(findEpisodeQuery, cleanPodcastURL, cleanEpisodeURL, userID).Scan(&episodeID)
|
||||
|
||||
if err == nil { // Episode found
|
||||
// Try to update existing history record
|
||||
var updateHistoryQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
updateHistoryQuery = `
|
||||
UPDATE "UserEpisodeHistory"
|
||||
SET ListenDuration = $1, ListenDate = $2
|
||||
WHERE UserID = $3 AND EpisodeID = $4
|
||||
`
|
||||
} else {
|
||||
updateHistoryQuery = `
|
||||
UPDATE UserEpisodeHistory
|
||||
SET ListenDuration = ?, ListenDate = ?
|
||||
WHERE UserID = ? AND EpisodeID = ?
|
||||
`
|
||||
}
|
||||
|
||||
result, err := tx.Exec(updateHistoryQuery, action.Position, time.Unix(actionTimestamp, 0), userID, episodeID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Error updating episode history: %v", err)
|
||||
} else {
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
// No history exists, create it
|
||||
var insertHistoryQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
insertHistoryQuery = `
|
||||
INSERT INTO "UserEpisodeHistory"
|
||||
(UserID, EpisodeID, ListenDuration, ListenDate)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (UserID, EpisodeID) DO UPDATE
|
||||
SET ListenDuration = $3, ListenDate = $4
|
||||
`
|
||||
} else {
|
||||
insertHistoryQuery = `
|
||||
INSERT INTO UserEpisodeHistory
|
||||
(UserID, EpisodeID, ListenDuration, ListenDate)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ListenDuration = VALUES(ListenDuration), ListenDate = VALUES(ListenDate)
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(insertHistoryQuery, userID, episodeID, action.Position, time.Unix(actionTimestamp, 0))
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] uploadEpisodeActions: Error creating episode history: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("[ERROR] uploadEpisodeActions: Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return response
|
||||
c.JSON(http.StatusOK, models.EpisodeActionResponse{
|
||||
Timestamp: timestamp,
|
||||
UpdateURLs: updateURLs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getFavoriteEpisodes handles GET /api/2/favorites/{username}.json
|
||||
func getFavoriteEpisodes(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
// Query for favorite episodes
|
||||
// Here we identify favorites by checking for episodes with the "is_favorite" setting
|
||||
var query string
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT
|
||||
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
||||
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
||||
FROM "Episodes" e
|
||||
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
||||
JOIN "GpodderSyncSettings" s ON s.UserID = p.UserID
|
||||
AND s.PodcastURL = p.FeedURL
|
||||
AND s.EpisodeURL = e.EpisodeURL
|
||||
WHERE s.UserID = $1
|
||||
AND s.Scope = 'episode'
|
||||
AND s.SettingKey = 'is_favorite'
|
||||
AND s.SettingValue = 'true'
|
||||
ORDER BY e.EpisodePubDate DESC
|
||||
`
|
||||
rows, err = database.Query(query, userID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
||||
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
||||
FROM Episodes e
|
||||
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
||||
JOIN GpodderSyncSettings s ON s.UserID = p.UserID
|
||||
AND s.PodcastURL = p.FeedURL
|
||||
AND s.EpisodeURL = e.EpisodeURL
|
||||
WHERE s.UserID = ?
|
||||
AND s.Scope = 'episode'
|
||||
AND s.SettingKey = 'is_favorite'
|
||||
AND s.SettingValue = 'true'
|
||||
ORDER BY e.EpisodePubDate DESC
|
||||
`
|
||||
rows, err = database.Query(query, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying favorite episodes: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get favorite episodes"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build response
|
||||
favorites := make([]models.Episode, 0)
|
||||
for rows.Next() {
|
||||
var episode models.Episode
|
||||
var pubDate time.Time
|
||||
if err := rows.Scan(
|
||||
&episode.Title,
|
||||
&episode.URL,
|
||||
&episode.Description,
|
||||
&episode.Website, // Using EpisodeArtwork for Website for now
|
||||
&episode.PodcastTitle,
|
||||
&episode.PodcastURL,
|
||||
&pubDate,
|
||||
); err != nil {
|
||||
log.Printf("Error scanning favorite episode: %v", err)
|
||||
continue
|
||||
}
|
||||
// Format the publication date in ISO 8601
|
||||
episode.Released = pubDate.Format(time.RFC3339)
|
||||
// Set MygpoLink (just a placeholder for now)
|
||||
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
|
||||
favorites = append(favorites, episode)
|
||||
}
|
||||
c.JSON(http.StatusOK, favorites)
|
||||
}
|
||||
}
|
||||
|
||||
// getEpisodeData handles GET /api/2/data/episode.json
|
||||
// getEpisodeData handles GET /api/2/data/episode.json
|
||||
func getEpisodeData(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
podcastURL := c.Query("podcast")
|
||||
episodeURL := c.Query("url")
|
||||
if podcastURL == "" || episodeURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Both podcast and url parameters are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query for episode data
|
||||
var episode models.Episode
|
||||
var pubDate time.Time
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT
|
||||
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
||||
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
||||
FROM "Episodes" e
|
||||
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
||||
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2
|
||||
LIMIT 1
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
||||
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
||||
FROM Episodes e
|
||||
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
||||
WHERE p.FeedURL = ? AND e.EpisodeURL = ?
|
||||
LIMIT 1
|
||||
`
|
||||
}
|
||||
|
||||
err := database.QueryRow(query, podcastURL, episodeURL).Scan(
|
||||
&episode.Title,
|
||||
&episode.URL,
|
||||
&episode.Description,
|
||||
&episode.Website, // Using EpisodeArtwork for Website for now
|
||||
&episode.PodcastTitle,
|
||||
&episode.PodcastURL,
|
||||
&pubDate,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Episode not found"})
|
||||
} else {
|
||||
log.Printf("Error querying episode data: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode data"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Format the publication date in ISO 8601
|
||||
episode.Released = pubDate.Format(time.RFC3339)
|
||||
// Set MygpoLink (just a placeholder for now)
|
||||
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
|
||||
c.JSON(http.StatusOK, episode)
|
||||
}
|
||||
}
|
||||
670
PinePods-0.8.2/gpodder-api/internal/api/list.go
Normal file
670
PinePods-0.8.2/gpodder-api/internal/api/list.go
Normal file
@@ -0,0 +1,670 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
"pinepods/gpodder-api/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getUserLists handles GET /api/2/lists/{username}.json
|
||||
func getUserLists(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware (if authenticated)
|
||||
userID, exists := c.Get("userID")
|
||||
username := c.Param("username")
|
||||
|
||||
// If not authenticated, get user ID from username
|
||||
if !exists {
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT UserID FROM "Users" WHERE Username = $1`
|
||||
} else {
|
||||
query = `SELECT UserID FROM Users WHERE Username = ?`
|
||||
}
|
||||
|
||||
err := database.QueryRow(query, username).Scan(&userID)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
} else {
|
||||
log.Printf("Error getting user ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Query for user's podcast lists
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT ListID, Name, Title
|
||||
FROM "GpodderSyncPodcastLists"
|
||||
WHERE UserID = $1
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ListID, Name, Title
|
||||
FROM GpodderSyncPodcastLists
|
||||
WHERE UserID = ?
|
||||
`
|
||||
}
|
||||
|
||||
rows, err := database.Query(query, userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying podcast lists: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast lists"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build response
|
||||
lists := make([]models.PodcastList, 0)
|
||||
for rows.Next() {
|
||||
var list models.PodcastList
|
||||
|
||||
if err := rows.Scan(&list.ListID, &list.Name, &list.Title); err != nil {
|
||||
log.Printf("Error scanning podcast list: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate web URL
|
||||
list.WebURL = fmt.Sprintf("/user/%s/lists/%s", username, list.Name)
|
||||
|
||||
lists = append(lists, list)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, lists)
|
||||
}
|
||||
}
|
||||
|
||||
// createPodcastList handles POST /api/2/lists/{username}/create
|
||||
func createPodcastList(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
username := c.Param("username")
|
||||
|
||||
// Get title from query parameter
|
||||
title := c.Query("title")
|
||||
if title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get format from query parameter or default to json
|
||||
format := c.Query("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// Parse body for podcast URLs
|
||||
var podcastURLs []string
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
if err := c.ShouldBindJSON(&podcastURLs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
case "txt":
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Split by newlines
|
||||
lines := strings.Split(string(body), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
podcastURLs = append(podcastURLs, line)
|
||||
}
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate name from title
|
||||
name := generateNameFromTitle(title)
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if a list with this name already exists
|
||||
var existingID int
|
||||
var existsQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
existsQuery = `
|
||||
SELECT ListID FROM "GpodderSyncPodcastLists"
|
||||
WHERE UserID = $1 AND Name = $2
|
||||
`
|
||||
} else {
|
||||
existsQuery = `
|
||||
SELECT ListID FROM GpodderSyncPodcastLists
|
||||
WHERE UserID = ? AND Name = ?
|
||||
`
|
||||
}
|
||||
|
||||
err = tx.QueryRow(existsQuery, userID, name).Scan(&existingID)
|
||||
|
||||
if err == nil {
|
||||
// List already exists
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A podcast list with this name already exists"})
|
||||
return
|
||||
} else if err != sql.ErrNoRows {
|
||||
log.Printf("Error checking list existence: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check list existence"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new list
|
||||
var listID int
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO "GpodderSyncPodcastLists" (UserID, Name, Title)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING ListID
|
||||
`, userID, name, title).Scan(&listID)
|
||||
} else {
|
||||
var result sql.Result
|
||||
result, err = tx.Exec(`
|
||||
INSERT INTO GpodderSyncPodcastLists (UserID, Name, Title)
|
||||
VALUES (?, ?, ?)
|
||||
`, userID, name, title)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
lastID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
log.Printf("Error getting last insert ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
listID = int(lastID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add podcasts to list
|
||||
for _, url := range podcastURLs {
|
||||
var insertQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
insertQuery = `
|
||||
INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL)
|
||||
VALUES ($1, $2)
|
||||
`
|
||||
} else {
|
||||
insertQuery = `
|
||||
INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(insertQuery, listID, url)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error adding podcast to list: %v", err)
|
||||
// Continue with other podcasts
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success with redirect location
|
||||
c.Header("Location", fmt.Sprintf("/api/2/lists/%s/list/%s?format=%s", username, name, format))
|
||||
c.Status(http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// getPodcastList handles GET /api/2/lists/{username}/list/{listname}
|
||||
func getPodcastList(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get username and listname from URL
|
||||
username := c.Param("username")
|
||||
listName := c.Param("listname")
|
||||
|
||||
// Get format from query parameter or default to json
|
||||
format := c.Query("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// Get user ID from username
|
||||
var userID int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `SELECT UserID FROM "Users" WHERE Username = $1`
|
||||
} else {
|
||||
query = `SELECT UserID FROM Users WHERE Username = ?`
|
||||
}
|
||||
|
||||
err := database.QueryRow(query, username).Scan(&userID)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
} else {
|
||||
log.Printf("Error getting user ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get list info
|
||||
var listID int
|
||||
var title string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT ListID, Title FROM "GpodderSyncPodcastLists"
|
||||
WHERE UserID = $1 AND Name = $2
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ListID, Title FROM GpodderSyncPodcastLists
|
||||
WHERE UserID = ? AND Name = ?
|
||||
`
|
||||
}
|
||||
|
||||
err = database.QueryRow(query, userID, listName).Scan(&listID, &title)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
|
||||
} else {
|
||||
log.Printf("Error getting podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get podcasts in list
|
||||
var rows *sql.Rows
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL
|
||||
FROM "GpodderSyncPodcastListEntries" e
|
||||
LEFT JOIN "Podcasts" p ON e.PodcastURL = p.FeedURL
|
||||
WHERE e.ListID = $1
|
||||
`
|
||||
rows, err = database.Query(query, listID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT e.PodcastURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL
|
||||
FROM GpodderSyncPodcastListEntries e
|
||||
LEFT JOIN Podcasts p ON e.PodcastURL = p.FeedURL
|
||||
WHERE e.ListID = ?
|
||||
`
|
||||
rows, err = database.Query(query, listID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying podcasts in list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcasts in list"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build podcast list
|
||||
podcasts := make([]models.Podcast, 0)
|
||||
for rows.Next() {
|
||||
var podcast models.Podcast
|
||||
var podcastName, description, author, artworkURL, websiteURL sql.NullString
|
||||
|
||||
if err := rows.Scan(&podcast.URL, &podcastName, &description, &author, &artworkURL, &websiteURL); err != nil {
|
||||
log.Printf("Error scanning podcast: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set values if present
|
||||
if podcastName.Valid {
|
||||
podcast.Title = podcastName.String
|
||||
} else {
|
||||
podcast.Title = podcast.URL
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
podcast.Description = description.String
|
||||
}
|
||||
|
||||
if author.Valid {
|
||||
podcast.Author = author.String
|
||||
}
|
||||
|
||||
if artworkURL.Valid {
|
||||
podcast.LogoURL = artworkURL.String
|
||||
}
|
||||
|
||||
if websiteURL.Valid {
|
||||
podcast.Website = websiteURL.String
|
||||
}
|
||||
|
||||
// Add MygpoLink
|
||||
podcast.MygpoLink = fmt.Sprintf("/podcast/%s", podcast.URL)
|
||||
|
||||
podcasts = append(podcasts, podcast)
|
||||
}
|
||||
|
||||
// Return in requested format
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, podcasts)
|
||||
case "txt":
|
||||
// Plain text format - just URLs
|
||||
var sb strings.Builder
|
||||
for _, podcast := range podcasts {
|
||||
sb.WriteString(podcast.URL)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
c.String(http.StatusOK, sb.String())
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname}
|
||||
// updatePodcastList handles PUT /api/2/lists/{username}/list/{listname}
|
||||
func updatePodcastList(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
listName := c.Param("listname")
|
||||
|
||||
// Get format from query parameter or default to json
|
||||
format := c.Query("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// Parse body for podcast URLs
|
||||
var podcastURLs []string
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
if err := c.ShouldBindJSON(&podcastURLs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
case "txt":
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Split by newlines
|
||||
lines := strings.Split(string(body), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
podcastURLs = append(podcastURLs, line)
|
||||
}
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
|
||||
return
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Get list ID
|
||||
var listID int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT ListID FROM "GpodderSyncPodcastLists"
|
||||
WHERE UserID = $1 AND Name = $2
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ListID FROM GpodderSyncPodcastLists
|
||||
WHERE UserID = ? AND Name = ?
|
||||
`
|
||||
}
|
||||
|
||||
err = tx.QueryRow(query, userID, listName).Scan(&listID)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
|
||||
} else {
|
||||
log.Printf("Error getting podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Remove existing entries
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncPodcastListEntries"
|
||||
WHERE ListID = $1
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncPodcastListEntries
|
||||
WHERE ListID = ?
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(query, listID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error removing existing entries: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add new entries
|
||||
for _, url := range podcastURLs {
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncPodcastListEntries" (ListID, PodcastURL)
|
||||
VALUES ($1, $2)
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderSyncPodcastListEntries (ListID, PodcastURL)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(query, listID, url)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error adding podcast to list: %v", err)
|
||||
// Continue with other podcasts
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname}
|
||||
// deletePodcastList handles DELETE /api/2/lists/{username}/list/{listname}
|
||||
func deletePodcastList(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
listName := c.Param("listname")
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Get list ID
|
||||
var listID int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT ListID FROM "GpodderSyncPodcastLists"
|
||||
WHERE UserID = $1 AND Name = $2
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ListID FROM GpodderSyncPodcastLists
|
||||
WHERE UserID = ? AND Name = ?
|
||||
`
|
||||
}
|
||||
|
||||
err = tx.QueryRow(query, userID, listName).Scan(&listID)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Podcast list not found"})
|
||||
} else {
|
||||
log.Printf("Error getting podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast list"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete list entries first (cascade should handle this, but being explicit)
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncPodcastListEntries"
|
||||
WHERE ListID = $1
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncPodcastListEntries
|
||||
WHERE ListID = ?
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(query, listID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error deleting list entries: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete list
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncPodcastLists"
|
||||
WHERE ListID = $1
|
||||
`
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncPodcastLists
|
||||
WHERE ListID = ?
|
||||
`
|
||||
}
|
||||
|
||||
_, err = tx.Exec(query, listID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error deleting podcast list: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete podcast list"})
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to generate a URL-friendly name from a title
|
||||
func generateNameFromTitle(title string) string {
|
||||
// Convert to lowercase
|
||||
name := strings.ToLower(title)
|
||||
|
||||
// Replace spaces with hyphens
|
||||
name = strings.ReplaceAll(name, " ", "-")
|
||||
|
||||
// Remove special characters
|
||||
re := regexp.MustCompile(`[^a-z0-9-]`)
|
||||
name = re.ReplaceAllString(name, "")
|
||||
|
||||
// Ensure name is not empty
|
||||
if name == "" {
|
||||
name = "list"
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
95
PinePods-0.8.2/gpodder-api/internal/api/routes.go
Normal file
95
PinePods-0.8.2/gpodder-api/internal/api/routes.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Add or update in routes.go to ensure the Episode API routes are registered:
|
||||
|
||||
// RegisterRoutes registers all API routes
|
||||
func RegisterRoutes(router *gin.RouterGroup, database *db.Database) {
|
||||
// Authentication endpoints
|
||||
log.Println("[INFO] Registering API routes...")
|
||||
authGroup := router.Group("/auth/:username")
|
||||
{
|
||||
authGroup.POST("/login.json", handleLogin(database))
|
||||
authGroup.POST("/logout.json", handleLogout(database))
|
||||
}
|
||||
// Device API
|
||||
log.Println("[INFO] Registering device routes")
|
||||
router.GET("/devices/:username.json", AuthenticationMiddleware(database), listDevices(database))
|
||||
router.POST("/devices/:username/:deviceid", AuthenticationMiddleware(database), updateDeviceData(database))
|
||||
router.GET("/updates/:username/:deviceid", AuthenticationMiddleware(database), getDeviceUpdates(database))
|
||||
|
||||
// Subscriptions API
|
||||
subscriptionsGroup := router.Group("/subscriptions/:username")
|
||||
subscriptionsGroup.Use(AuthenticationMiddleware(database))
|
||||
{
|
||||
subscriptionsGroup.GET("/:deviceid", getSubscriptions(database))
|
||||
subscriptionsGroup.PUT("/:deviceid", updateSubscriptions(database))
|
||||
subscriptionsGroup.POST("/:deviceid", uploadSubscriptionChanges(database))
|
||||
// All subscriptions endpoint (since 2.11)
|
||||
subscriptionsGroup.GET(".json", getAllSubscriptions(database))
|
||||
}
|
||||
|
||||
// Episode Actions API - FIXED ROUTE PATTERN
|
||||
log.Println("[INFO] Registering episode actions routes")
|
||||
// Register directly on the router without a group
|
||||
router.GET("/episodes/:username.json", AuthenticationMiddleware(database), getEpisodeActions(database))
|
||||
router.POST("/episodes/:username.json", AuthenticationMiddleware(database), uploadEpisodeActions(database))
|
||||
|
||||
// Settings API
|
||||
settingsGroup := router.Group("/settings/:username")
|
||||
settingsGroup.Use(AuthenticationMiddleware(database))
|
||||
{
|
||||
settingsGroup.GET("/:scope.json", getSettings(database))
|
||||
settingsGroup.POST("/:scope.json", saveSettings(database))
|
||||
}
|
||||
|
||||
// Podcast Lists API
|
||||
listsGroup := router.Group("/lists/:username")
|
||||
{
|
||||
listsGroup.GET(".json", getUserLists(database))
|
||||
listsGroup.POST("/create", AuthenticationMiddleware(database), createPodcastList(database))
|
||||
listGroup := listsGroup.Group("/list/:listname")
|
||||
{
|
||||
listGroup.GET("", getPodcastList(database))
|
||||
listGroup.PUT("", AuthenticationMiddleware(database), updatePodcastList(database))
|
||||
listGroup.DELETE("", AuthenticationMiddleware(database), deletePodcastList(database))
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite Episodes API
|
||||
router.GET("/favorites/:username.json", AuthenticationMiddleware(database), getFavoriteEpisodes(database))
|
||||
|
||||
// Device Synchronization API
|
||||
syncGroup := router.Group("/sync-devices/:username")
|
||||
syncGroup.Use(AuthenticationMiddleware(database))
|
||||
{
|
||||
syncGroup.GET(".json", getSyncStatus(database))
|
||||
syncGroup.POST(".json", updateSyncStatus(database))
|
||||
}
|
||||
|
||||
// Directory API (no auth required)
|
||||
router.GET("/tags/:count.json", getTopTags(database))
|
||||
router.GET("/tag/:tag/:count.json", getPodcastsForTag(database))
|
||||
router.GET("/data/podcast.json", getPodcastData(database))
|
||||
router.GET("/data/episode.json", getEpisodeData(database))
|
||||
|
||||
// Suggestions API (auth required)
|
||||
router.GET("/suggestions/:count", AuthenticationMiddleware(database), getSuggestions(database))
|
||||
}
|
||||
|
||||
// RegisterSimpleRoutes registers routes for the Simple API (v1)
|
||||
func RegisterSimpleRoutes(router *gin.RouterGroup, database *db.Database) {
|
||||
// Toplist
|
||||
router.GET("/toplist/:number", getToplist(database))
|
||||
// Search
|
||||
router.GET("/search", podcastSearch(database))
|
||||
// Subscriptions (Simple API)
|
||||
router.GET("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), getSubscriptionsSimple(database))
|
||||
router.PUT("/subscriptions/:username/:deviceid", AuthenticationMiddleware(database), updateSubscriptionsSimple(database))
|
||||
}
|
||||
991
PinePods-0.8.2/gpodder-api/internal/api/settings.go
Normal file
991
PinePods-0.8.2/gpodder-api/internal/api/settings.go
Normal file
@@ -0,0 +1,991 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
"pinepods/gpodder-api/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Constants for settings
|
||||
const (
|
||||
MAX_SETTING_KEY_LENGTH = 255
|
||||
MAX_SETTING_VALUE_LENGTH = 8192
|
||||
MAX_SETTINGS_PER_REQUEST = 50
|
||||
)
|
||||
|
||||
// Known settings that trigger behavior
|
||||
var knownSettings = map[string]map[string]bool{
|
||||
"account": {
|
||||
"public_profile": true,
|
||||
"store_user_agent": true,
|
||||
"public_subscriptions": true,
|
||||
"color_theme": true,
|
||||
"default_subscribe_all": true,
|
||||
},
|
||||
"episode": {
|
||||
"is_favorite": true,
|
||||
"played": true,
|
||||
"current_position": true,
|
||||
},
|
||||
"podcast": {
|
||||
"public_subscription": true,
|
||||
"auto_download": true,
|
||||
"episode_sort": true,
|
||||
},
|
||||
"device": {
|
||||
"auto_update": true,
|
||||
"update_interval": true,
|
||||
"wifi_only_downloads": true,
|
||||
"max_episodes_per_feed": true,
|
||||
},
|
||||
}
|
||||
|
||||
// Validation interfaces and functions
|
||||
|
||||
// ValueValidator defines interface for validating settings values
|
||||
type ValueValidator interface {
|
||||
Validate(value interface{}) bool
|
||||
}
|
||||
|
||||
// BooleanValidator validates boolean values
|
||||
type BooleanValidator struct{}
|
||||
|
||||
func (v BooleanValidator) Validate(value interface{}) bool {
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IntValidator validates integer values
|
||||
type IntValidator struct {
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
|
||||
func (v IntValidator) Validate(value interface{}) bool {
|
||||
num, ok := value.(float64) // JSON numbers are parsed as float64
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a whole number
|
||||
if num != float64(int(num)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check range if specified
|
||||
intVal := int(num)
|
||||
if v.Min != 0 || v.Max != 0 {
|
||||
if intVal < v.Min || (v.Max != 0 && intVal > v.Max) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// StringValidator validates string values
|
||||
type StringValidator struct {
|
||||
AllowedValues []string
|
||||
MaxLength int
|
||||
}
|
||||
|
||||
func (v StringValidator) Validate(value interface{}) bool {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check maximum length if specified
|
||||
if v.MaxLength > 0 && utf8.RuneCountInString(str) > v.MaxLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check allowed values if specified
|
||||
if len(v.AllowedValues) > 0 {
|
||||
for _, allowed := range v.AllowedValues {
|
||||
if str == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// validation rules for specific settings
|
||||
var settingValidators = map[string]map[string]ValueValidator{
|
||||
"account": {
|
||||
"public_profile": BooleanValidator{},
|
||||
"store_user_agent": BooleanValidator{},
|
||||
"public_subscriptions": BooleanValidator{},
|
||||
"default_subscribe_all": BooleanValidator{},
|
||||
"color_theme": StringValidator{AllowedValues: []string{"light", "dark", "system"}, MaxLength: 10},
|
||||
},
|
||||
"episode": {
|
||||
"is_favorite": BooleanValidator{},
|
||||
"played": BooleanValidator{},
|
||||
"current_position": IntValidator{Min: 0},
|
||||
},
|
||||
"podcast": {
|
||||
"public_subscription": BooleanValidator{},
|
||||
"auto_download": BooleanValidator{},
|
||||
"episode_sort": StringValidator{AllowedValues: []string{"newest_first", "oldest_first", "title"}, MaxLength: 20},
|
||||
},
|
||||
"device": {
|
||||
"auto_update": BooleanValidator{},
|
||||
"update_interval": IntValidator{Min: 10, Max: 1440}, // 10 minutes to 24 hours
|
||||
"wifi_only_downloads": BooleanValidator{},
|
||||
"max_episodes_per_feed": IntValidator{Min: 1, Max: 1000},
|
||||
},
|
||||
}
|
||||
|
||||
// validateSettingValue validates a setting value based on its scope and key
|
||||
func validateSettingValue(scope, key string, value interface{}) (bool, string) {
|
||||
// Maximum setting value length check
|
||||
jsonValue, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return false, "Failed to serialize setting value"
|
||||
}
|
||||
|
||||
if len(jsonValue) > MAX_SETTING_VALUE_LENGTH {
|
||||
return false, "Setting value exceeds maximum length"
|
||||
}
|
||||
|
||||
// Check if we have a specific validator for this setting
|
||||
if validators, ok := settingValidators[scope]; ok {
|
||||
if validator, ok := validators[key]; ok {
|
||||
if !validator.Validate(value) {
|
||||
return false, "Setting value failed validation for the specified scope and key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// getSettings handles GET /api/2/settings/{username}/{scope}.json
|
||||
func getSettings(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from context (set by AuthMiddleware)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get scope from URL
|
||||
scope := c.Param("scope")
|
||||
if scope == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid scope. Valid values are: account, device, podcast, episode",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
deviceID := c.Query("device")
|
||||
podcastURL := c.Query("podcast")
|
||||
episodeURL := c.Query("episode")
|
||||
|
||||
// Validate parameters based on scope
|
||||
if scope == "device" && deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"})
|
||||
return
|
||||
}
|
||||
if scope == "podcast" && podcastURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"})
|
||||
return
|
||||
}
|
||||
if scope == "episode" && (podcastURL == "" || episodeURL == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build query based on scope
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
switch scope {
|
||||
case "account":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope)
|
||||
} else {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ?
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope)
|
||||
}
|
||||
case "device":
|
||||
// Get device ID from name
|
||||
var deviceIDInt int
|
||||
var deviceQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
deviceQuery = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
|
||||
`
|
||||
} else {
|
||||
deviceQuery = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
|
||||
`
|
||||
}
|
||||
|
||||
err := database.QueryRow(deviceQuery, userID, deviceID).Scan(&deviceIDInt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("Device not found: %s", deviceID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found or not active"})
|
||||
} else {
|
||||
log.Printf("Error getting device ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, deviceIDInt)
|
||||
} else {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND DeviceID = ?
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, deviceIDInt)
|
||||
}
|
||||
case "podcast":
|
||||
// Validate podcast URL
|
||||
if !isValidURL(podcastURL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"})
|
||||
return
|
||||
}
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL)
|
||||
} else {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ?
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL)
|
||||
}
|
||||
case "episode":
|
||||
// Validate URLs
|
||||
if !isValidURL(podcastURL) || !isValidURL(episodeURL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"})
|
||||
return
|
||||
}
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL)
|
||||
} else {
|
||||
query = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ?
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Query settings
|
||||
rows, err := database.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Error querying settings: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build settings map
|
||||
settings := make(map[string]interface{})
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
log.Printf("Error scanning setting row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to unmarshal as JSON, fallback to string if not valid JSON
|
||||
var jsonValue interface{}
|
||||
if err := json.Unmarshal([]byte(value), &jsonValue); err != nil {
|
||||
// Not valid JSON, use as string
|
||||
settings[key] = value
|
||||
} else {
|
||||
// Valid JSON, use parsed value
|
||||
settings[key] = jsonValue
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Error iterating setting rows: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return settings
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// isValidURL performs basic URL validation
|
||||
func isValidURL(urlStr string) bool {
|
||||
// Check if empty
|
||||
if urlStr == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must start with http:// or https://
|
||||
if !strings.HasPrefix(strings.ToLower(urlStr), "http://") &&
|
||||
!strings.HasPrefix(strings.ToLower(urlStr), "https://") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Basic length check
|
||||
if len(urlStr) < 10 || len(urlStr) > 2048 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// saveSettings handles POST /api/2/settings/{username}/{scope}.json
|
||||
func saveSettings(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from context (set by AuthMiddleware)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get scope from URL
|
||||
scope := c.Param("scope")
|
||||
if scope == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
if scope != "account" && scope != "device" && scope != "podcast" && scope != "episode" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid scope. Valid values are: account, device, podcast, episode",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
deviceName := c.Query("device")
|
||||
podcastURL := c.Query("podcast")
|
||||
episodeURL := c.Query("episode")
|
||||
|
||||
// Validate parameters based on scope
|
||||
if scope == "device" && deviceName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required for device scope"})
|
||||
return
|
||||
}
|
||||
if scope == "podcast" && podcastURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL is required for podcast scope"})
|
||||
return
|
||||
}
|
||||
if scope == "episode" && (podcastURL == "" || episodeURL == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Podcast URL and Episode URL are required for episode scope"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
if scope == "podcast" && !isValidURL(podcastURL) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast URL"})
|
||||
return
|
||||
}
|
||||
if scope == "episode" && (!isValidURL(podcastURL) || !isValidURL(episodeURL)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid podcast or episode URL"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.SettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("Error parsing request: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'set' and 'remove' properties"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request size
|
||||
if len(req.Set) > MAX_SETTINGS_PER_REQUEST || len(req.Remove) > MAX_SETTINGS_PER_REQUEST {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Too many settings in request. Maximum allowed: %d", MAX_SETTINGS_PER_REQUEST),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Process device ID if needed
|
||||
var deviceID *int
|
||||
if scope == "device" {
|
||||
var deviceIDInt int
|
||||
var deviceQuery string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
deviceQuery = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
|
||||
`
|
||||
} else {
|
||||
deviceQuery = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
|
||||
`
|
||||
}
|
||||
|
||||
err := database.QueryRow(deviceQuery, userID, deviceName).Scan(&deviceIDInt)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// Create the device if it doesn't exist
|
||||
if database.IsPostgreSQLDB() {
|
||||
deviceQuery = `
|
||||
INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
||||
VALUES ($1, $2, 'other', true, $3)
|
||||
RETURNING DeviceID
|
||||
`
|
||||
err = database.QueryRow(deviceQuery, userID, deviceName, time.Now()).Scan(&deviceIDInt)
|
||||
} else {
|
||||
deviceQuery = `
|
||||
INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
||||
VALUES (?, ?, 'other', true, ?)
|
||||
`
|
||||
result, err := database.Exec(deviceQuery, userID, deviceName, "other", time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Error creating device: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
|
||||
lastID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
log.Printf("Error getting last insert ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
|
||||
deviceIDInt = int(lastID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating device: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Printf("Error getting device ID: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deviceID = &deviceIDInt
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Process settings to set
|
||||
for key, value := range req.Set {
|
||||
// Validate key
|
||||
if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH {
|
||||
log.Printf("Invalid setting key length: %s", key)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Allow only letters, numbers, underscores and hyphens
|
||||
if !isValidSettingKey(key) {
|
||||
log.Printf("Invalid setting key: %s", key)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate value
|
||||
valid, errMsg := validateSettingValue(scope, key, value)
|
||||
if !valid {
|
||||
log.Printf("Invalid setting value for key %s: %s", key, errMsg)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Invalid value for key '%s': %s", key, errMsg),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert value to JSON string
|
||||
jsonValue, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling value to JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid value for key: " + key})
|
||||
return
|
||||
}
|
||||
|
||||
// Build query based on scope
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
switch scope {
|
||||
case "account":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncSettings" (UserID, Scope, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (UserID, Scope, SettingKey)
|
||||
WHERE DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
DO UPDATE SET SettingValue = $4, LastUpdated = $5
|
||||
`
|
||||
args = append(args, userID, scope, key, string(jsonValue), time.Now())
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderSyncSettings (UserID, Scope, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
|
||||
`
|
||||
args = append(args, userID, scope, key, string(jsonValue), time.Now())
|
||||
}
|
||||
case "device":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncSettings" (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (UserID, Scope, SettingKey, DeviceID)
|
||||
WHERE PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
DO UPDATE SET SettingValue = $5, LastUpdated = $6
|
||||
`
|
||||
args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now())
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderSyncSettings (UserID, Scope, DeviceID, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
|
||||
`
|
||||
args = append(args, userID, scope, deviceID, key, string(jsonValue), time.Now())
|
||||
}
|
||||
case "podcast":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (UserID, Scope, SettingKey, PodcastURL)
|
||||
WHERE DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
DO UPDATE SET SettingValue = $5, LastUpdated = $6
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now())
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, key, string(jsonValue), time.Now())
|
||||
}
|
||||
case "episode":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncSettings" (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (UserID, Scope, SettingKey, PodcastURL, EpisodeURL)
|
||||
WHERE DeviceID IS NULL
|
||||
DO UPDATE SET SettingValue = $6, LastUpdated = $7
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now())
|
||||
} else {
|
||||
query = `
|
||||
INSERT INTO GpodderSyncSettings (UserID, Scope, PodcastURL, EpisodeURL, SettingKey, SettingValue, LastUpdated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE SettingValue = VALUES(SettingValue), LastUpdated = VALUES(LastUpdated)
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL, key, string(jsonValue), time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// Execute query
|
||||
_, err = tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Error setting value for key %s: %v", key, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Process settings to remove
|
||||
for _, key := range req.Remove {
|
||||
// Validate key
|
||||
if len(key) == 0 || len(key) > MAX_SETTING_KEY_LENGTH {
|
||||
log.Printf("Invalid setting key length: %s", key)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Invalid setting key: must be between 1 and %d characters", MAX_SETTING_KEY_LENGTH),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Allow only letters, numbers, underscores and hyphens
|
||||
if !isValidSettingKey(key) {
|
||||
log.Printf("Invalid setting key: %s", key)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Invalid setting key: must contain only letters, numbers, underscores and hyphens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build query based on scope
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
switch scope {
|
||||
case "account":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND SettingKey = $3
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, key)
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND SettingKey = ?
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, key)
|
||||
}
|
||||
case "device":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3 AND SettingKey = $4
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, deviceID, key)
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND DeviceID = ? AND SettingKey = ?
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, deviceID, key)
|
||||
}
|
||||
case "podcast":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND SettingKey = $4
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, key)
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND SettingKey = ?
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, key)
|
||||
}
|
||||
case "episode":
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4 AND SettingKey = $5
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL, key)
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ? AND SettingKey = ?
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
args = append(args, userID, scope, podcastURL, episodeURL, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute query
|
||||
_, err = tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Error removing key %s: %v", key, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Query all settings for the updated response
|
||||
var queryAll string
|
||||
var argsAll []interface{}
|
||||
|
||||
switch scope {
|
||||
case "account":
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope)
|
||||
} else {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ?
|
||||
AND DeviceID IS NULL AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope)
|
||||
}
|
||||
case "device":
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND DeviceID = $3
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, deviceID)
|
||||
} else {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND DeviceID = ?
|
||||
AND PodcastURL IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, deviceID)
|
||||
}
|
||||
case "podcast":
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, podcastURL)
|
||||
} else {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ?
|
||||
AND DeviceID IS NULL AND EpisodeURL IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, podcastURL)
|
||||
}
|
||||
case "episode":
|
||||
if database.IsPostgreSQLDB() {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM "GpodderSyncSettings"
|
||||
WHERE UserID = $1 AND Scope = $2 AND PodcastURL = $3 AND EpisodeURL = $4
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, podcastURL, episodeURL)
|
||||
} else {
|
||||
queryAll = `
|
||||
SELECT SettingKey, SettingValue
|
||||
FROM GpodderSyncSettings
|
||||
WHERE UserID = ? AND Scope = ? AND PodcastURL = ? AND EpisodeURL = ?
|
||||
AND DeviceID IS NULL
|
||||
`
|
||||
argsAll = append(argsAll, userID, scope, podcastURL, episodeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Query all settings
|
||||
rows, err := database.Query(queryAll, argsAll...)
|
||||
if err != nil {
|
||||
log.Printf("Error querying all settings: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build settings map
|
||||
settings := make(map[string]interface{})
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
log.Printf("Error scanning setting row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to unmarshal as JSON, fallback to string if not valid JSON
|
||||
var jsonValue interface{}
|
||||
if err := json.Unmarshal([]byte(value), &jsonValue); err != nil {
|
||||
// Not valid JSON, use as string
|
||||
settings[key] = value
|
||||
} else {
|
||||
// Valid JSON, use parsed value
|
||||
settings[key] = jsonValue
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Error iterating setting rows: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{}) // Return empty object in case of error
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated settings
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// isValidSettingKey checks if the key contains only valid characters
|
||||
func isValidSettingKey(key string) bool {
|
||||
for _, r := range key {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// toggleGpodderAPI is a Pinepods-specific extension to enable/disable the gpodder API for a user
|
||||
func toggleGpodderAPI(database *db.PostgresDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from context (set by AuthMiddleware)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req struct {
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set Pod_Sync_Type based on enable flag
|
||||
var podSyncType string
|
||||
if req.Enable {
|
||||
// Check if external gpodder sync is already enabled
|
||||
var currentSyncType string
|
||||
err := database.QueryRow(`
|
||||
SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1
|
||||
`, userID).Scan(¤tSyncType)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error getting current sync type: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
|
||||
return
|
||||
}
|
||||
|
||||
if currentSyncType == "external" {
|
||||
podSyncType = "both"
|
||||
} else {
|
||||
podSyncType = "gpodder"
|
||||
}
|
||||
} else {
|
||||
// Check if external gpodder sync is enabled
|
||||
var currentSyncType string
|
||||
err := database.QueryRow(`
|
||||
SELECT Pod_Sync_Type FROM "Users" WHERE UserID = $1
|
||||
`, userID).Scan(¤tSyncType)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error getting current sync type: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
|
||||
return
|
||||
}
|
||||
|
||||
if currentSyncType == "both" {
|
||||
podSyncType = "external"
|
||||
} else {
|
||||
podSyncType = "None"
|
||||
}
|
||||
}
|
||||
|
||||
// Update user's Pod_Sync_Type
|
||||
_, err := database.Exec(`
|
||||
UPDATE "Users" SET Pod_Sync_Type = $1 WHERE UserID = $2
|
||||
`, podSyncType, userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error updating Pod_Sync_Type: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to toggle gpodder API"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": req.Enable,
|
||||
"sync_type": podSyncType,
|
||||
})
|
||||
}
|
||||
}
|
||||
1878
PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go
Normal file
1878
PinePods-0.8.2/gpodder-api/internal/api/subscriptions.go
Normal file
File diff suppressed because it is too large
Load Diff
267
PinePods-0.8.2/gpodder-api/internal/api/sync.go
Normal file
267
PinePods-0.8.2/gpodder-api/internal/api/sync.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"pinepods/gpodder-api/internal/db"
|
||||
"pinepods/gpodder-api/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getSyncStatus handles GET /api/2/sync-devices/{username}.json
|
||||
func getSyncStatus(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
// Query for device sync pairs
|
||||
var query string
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT d1.DeviceName, d2.DeviceName
|
||||
FROM "GpodderSyncDevicePairs" p
|
||||
JOIN "GpodderDevices" d1 ON p.DeviceID1 = d1.DeviceID
|
||||
JOIN "GpodderDevices" d2 ON p.DeviceID2 = d2.DeviceID
|
||||
WHERE p.UserID = $1
|
||||
`
|
||||
rows, err = database.Query(query, userID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT d1.DeviceName, d2.DeviceName
|
||||
FROM GpodderSyncDevicePairs p
|
||||
JOIN GpodderDevices d1 ON p.DeviceID1 = d1.DeviceID
|
||||
JOIN GpodderDevices d2 ON p.DeviceID2 = d2.DeviceID
|
||||
WHERE p.UserID = ?
|
||||
`
|
||||
rows, err = database.Query(query, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying device sync pairs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build sync pairs
|
||||
syncPairs := make([][]string, 0)
|
||||
for rows.Next() {
|
||||
var device1, device2 string
|
||||
if err := rows.Scan(&device1, &device2); err != nil {
|
||||
log.Printf("Error scanning device pair: %v", err)
|
||||
continue
|
||||
}
|
||||
syncPairs = append(syncPairs, []string{device1, device2})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Query for devices not in any sync pair
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT d.DeviceName
|
||||
FROM "GpodderDevices" d
|
||||
WHERE d.UserID = $1
|
||||
AND d.DeviceID NOT IN (
|
||||
SELECT DeviceID1 FROM "GpodderSyncDevicePairs" WHERE UserID = $1
|
||||
UNION
|
||||
SELECT DeviceID2 FROM "GpodderSyncDevicePairs" WHERE UserID = $1
|
||||
)
|
||||
`
|
||||
rows, err = database.Query(query, userID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT d.DeviceName
|
||||
FROM GpodderDevices d
|
||||
WHERE d.UserID = ?
|
||||
AND d.DeviceID NOT IN (
|
||||
SELECT DeviceID1 FROM GpodderSyncDevicePairs WHERE UserID = ?
|
||||
UNION
|
||||
SELECT DeviceID2 FROM GpodderSyncDevicePairs WHERE UserID = ?
|
||||
)
|
||||
`
|
||||
rows, err = database.Query(query, userID, userID, userID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error querying non-synced devices: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync status"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build non-synced devices list
|
||||
nonSynced := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var deviceName string
|
||||
if err := rows.Scan(&deviceName); err != nil {
|
||||
log.Printf("Error scanning non-synced device: %v", err)
|
||||
continue
|
||||
}
|
||||
nonSynced = append(nonSynced, deviceName)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Return response
|
||||
c.JSON(http.StatusOK, models.SyncDevicesResponse{
|
||||
Synchronized: syncPairs,
|
||||
NotSynchronized: nonSynced,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// updateSyncStatus handles POST /api/2/sync-devices/{username}.json
|
||||
func updateSyncStatus(database *db.Database) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get user ID from middleware
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
// Parse request
|
||||
var req models.SyncDevicesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx, err := database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Process synchronize pairs
|
||||
for _, pair := range req.Synchronize {
|
||||
if len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get device IDs
|
||||
var device1ID, device2ID int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2
|
||||
`
|
||||
err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ?
|
||||
`
|
||||
err = tx.QueryRow(query, userID, pair[0]).Scan(&device1ID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error getting device ID for %s: %v", pair[0], err)
|
||||
continue
|
||||
}
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID)
|
||||
} else {
|
||||
err = tx.QueryRow(query, userID, pair[1]).Scan(&device2ID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error getting device ID for %s: %v", pair[1], err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure device1ID < device2ID for consistency
|
||||
if device1ID > device2ID {
|
||||
device1ID, device2ID = device2ID, device1ID
|
||||
}
|
||||
|
||||
// Insert sync pair if it doesn't exist
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
INSERT INTO "GpodderSyncDevicePairs" (UserID, DeviceID1, DeviceID2)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (UserID, DeviceID1, DeviceID2) DO NOTHING
|
||||
`
|
||||
_, err = tx.Exec(query, userID, device1ID, device2ID)
|
||||
} else {
|
||||
query = `
|
||||
INSERT IGNORE INTO GpodderSyncDevicePairs (UserID, DeviceID1, DeviceID2)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
_, err = tx.Exec(query, userID, device1ID, device2ID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating sync pair: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create sync pair"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Process stop-synchronize devices
|
||||
for _, deviceName := range req.StopSynchronize {
|
||||
// Get device ID
|
||||
var deviceID int
|
||||
var query string
|
||||
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
SELECT DeviceID FROM "GpodderDevices"
|
||||
WHERE UserID = $1 AND DeviceName = $2
|
||||
`
|
||||
err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID)
|
||||
} else {
|
||||
query = `
|
||||
SELECT DeviceID FROM GpodderDevices
|
||||
WHERE UserID = ? AND DeviceName = ?
|
||||
`
|
||||
err = tx.QueryRow(query, userID, deviceName).Scan(&deviceID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error getting device ID for %s: %v", deviceName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove all sync pairs involving this device
|
||||
if database.IsPostgreSQLDB() {
|
||||
query = `
|
||||
DELETE FROM "GpodderSyncDevicePairs"
|
||||
WHERE UserID = $1 AND (DeviceID1 = $2 OR DeviceID2 = $2)
|
||||
`
|
||||
_, err = tx.Exec(query, userID, deviceID)
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM GpodderSyncDevicePairs
|
||||
WHERE UserID = ? AND (DeviceID1 = ? OR DeviceID2 = ?)
|
||||
`
|
||||
_, err = tx.Exec(query, userID, deviceID, deviceID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error removing sync pairs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove sync pairs"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated sync status by reusing the getSyncStatus handler
|
||||
getSyncStatus(database)(c)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user