992 lines
29 KiB
Go
992 lines
29 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|