+
Pinepods Helm Chart
+

+
+ Welcome to the Pinepods Helm chart repository. Follow the
+ instructions below to use the Helm chart.
+
+
+
Adding the Repository
+
helm repo add pinepods http://helm.pinepods.online/
+helm repo update
+
+
Create the namespace
+
kubectl create namespace pinepods-namespace
+
+
Customizing Values
+
+ Create a my-values.yaml file to override default
+ values:
+
+
replicaCount: 2
+
+ image:
+ repository: pinepods
+ tag: latest
+ pullPolicy: IfNotPresent
+
+ service:
+ type: NodePort
+ port: 8040
+ nodePort: 30007
+
+ persistence:
+ enabled: true
+ accessMode: ReadWriteOnce
+ size: 10Gi
+
+ postgresql:
+ enabled: true
+ auth:
+ username: postgres
+ password: "supersecretpassword"
+ database: pinepods_database
+ primary:
+ persistence:
+ enabled: true
+ existingClaim: postgres-pvc
+
+ env:
+ SEARCH_API_URL: "https://search.pinepods.online/api/search"
+ USERNAME: "admin"
+ PASSWORD: "password"
+ FULLNAME: "Admin User"
+ EMAIL: "admin@example.com"
+ DB_TYPE: "postgresql"
+ DB_HOST: "pinepods-postgresql.pinepods-namespace.svc.cluster.local"
+ DB_PORT: "5432"
+ DB_USER: "postgres"
+ DB_NAME: "pinepods_database"
+ DEBUG_MODE: "false"
+
+
Installing the Chart
+
helm install pinepods pinepods/pinepods -f my-values.yaml --namespace pinepods-namespace
+
+
More Information
+
+ For more information, visit the
+ GitHub repository.
+
+
+
+
diff --git a/PinePods-0.8.2/docs/index.yaml b/PinePods-0.8.2/docs/index.yaml
new file mode 100644
index 0000000..498dea2
--- /dev/null
+++ b/PinePods-0.8.2/docs/index.yaml
@@ -0,0 +1,264 @@
+apiVersion: v1
+entries:
+ pinepods:
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.683959727Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 28e32586ecbdfc1749890007055c61add7b78076cee90980d425113b38b13b9c
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.8.1.tgz
+ version: 0.8.1
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.67456305Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: e08c788d3d225ca3caef37c030f27d7c25cd4ecc557f7fe2f32215ff7f164ba8
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.8.tgz
+ version: 0.7.8
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.665123097Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 2956c727f65099059680638f2529f472affe25cbd9d6ad90b593dbf7444d6648
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.7.tgz
+ version: 0.7.7
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.655783861Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 7b954cac8ed6cdff756090d56fc3c98342f6ca944922f2c910b7e20fb338ce5a
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.6.tgz
+ version: 0.7.6
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.646315323Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: a69290e9e9051ac4442f19bea78cca08bdd91a831e6c91af06d48eb6b9a07409
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.5.tgz
+ version: 0.7.5
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.636858843Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 2f5f97abadf581a025315cb288c139274b4411c86f1e46e52944f89e76c23a9c
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.4.tgz
+ version: 0.7.4
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.626710376Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 47208597c5b52c4d8c9fb659416fe7d679f6fc5a099d9b37caaae0ecfeb33dde
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.3.tgz
+ version: 0.7.3
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.61704642Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: ef86847694c2291c9ebcd4ec40d4f4680c5b675aafc7c82693c3119f5ae4b43b
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.2.tgz
+ version: 0.7.2
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.607497209Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 07ba1a2859213a3542e03aa922d0dfd0f61d146cd3938e85216a740c1ee90bd4
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.1.tgz
+ version: 0.7.1
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.597450971Z"
+ dependencies:
+ - condition: postgresql.enabled
+ name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ - condition: valkey.enabled
+ name: valkey
+ repository: https://charts.bitnami.com/bitnami
+ version: 2.0.1
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 1815802cc08ed83c3eaedd2ac28d6fbd044a817e22c7e8cda4af38bffcde9d82
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.7.0.tgz
+ version: 0.7.0
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.588288452Z"
+ dependencies:
+ - name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: c994e0c57c47448718cc849d103ab91f21a427b7580a0ef0b4c4decc613a04ad
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.6.6.tgz
+ version: 0.6.6
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.584369055Z"
+ dependencies:
+ - name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 82f6f1d9569626aab1920bc574b3616c77aadbf4c9b6bc1d5cc5f51cc3bc41f2
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.6.5.tgz
+ version: 0.6.5
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.579496258Z"
+ dependencies:
+ - name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: c2ab21d0cb61a2e809432f762aca608723365d87e8840d02b29c08bc105c9a31
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.6.4.tgz
+ version: 0.6.4
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.574870899Z"
+ dependencies:
+ - name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: 3a0a1b86a6fb22888fead9fc12b84e8845a55ff6736775445028d181318860bf
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.6.3.tgz
+ version: 0.6.3
+ - apiVersion: v2
+ created: "2025-10-30T11:52:41.57094979Z"
+ dependencies:
+ - name: postgresql
+ repository: https://charts.bitnami.com/bitnami
+ version: 15.5.14
+ description: A Helm chart for deploying Pinepods - A complete podcast management
+ system and allows you to play, download, and keep track of podcasts you enjoy.
+ All self hosted and enjoyed on your own server!
+ digest: f7148434be4b395aaab8bb76fbe1584f7de8fb981b2471506b33ff11be85f706
+ name: pinepods
+ urls:
+ - https://helm.pinepods.online/pinepods-0.6.2.tgz
+ version: 0.6.2
+generated: "2025-10-30T11:52:41.565858306Z"
diff --git a/PinePods-0.8.2/docs/pinepods-0.6.2.tgz b/PinePods-0.8.2/docs/pinepods-0.6.2.tgz
new file mode 100644
index 0000000..e7c0807
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.2.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.6.3.tgz b/PinePods-0.8.2/docs/pinepods-0.6.3.tgz
new file mode 100644
index 0000000..3e27666
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.3.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.6.4.tgz b/PinePods-0.8.2/docs/pinepods-0.6.4.tgz
new file mode 100644
index 0000000..62cd8a1
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.4.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.6.5.tgz b/PinePods-0.8.2/docs/pinepods-0.6.5.tgz
new file mode 100644
index 0000000..2327d12
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.5.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.6.6.tgz b/PinePods-0.8.2/docs/pinepods-0.6.6.tgz
new file mode 100644
index 0000000..745bddd
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.6.6.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.0.tgz b/PinePods-0.8.2/docs/pinepods-0.7.0.tgz
new file mode 100644
index 0000000..6ab2006
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.0.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.1.tgz b/PinePods-0.8.2/docs/pinepods-0.7.1.tgz
new file mode 100644
index 0000000..ae216d1
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.1.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.2.tgz b/PinePods-0.8.2/docs/pinepods-0.7.2.tgz
new file mode 100644
index 0000000..b509fd1
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.2.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.3.tgz b/PinePods-0.8.2/docs/pinepods-0.7.3.tgz
new file mode 100644
index 0000000..14eae02
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.3.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.4.tgz b/PinePods-0.8.2/docs/pinepods-0.7.4.tgz
new file mode 100644
index 0000000..56d6b2a
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.4.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.5.tgz b/PinePods-0.8.2/docs/pinepods-0.7.5.tgz
new file mode 100644
index 0000000..830c166
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.5.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.6.tgz b/PinePods-0.8.2/docs/pinepods-0.7.6.tgz
new file mode 100644
index 0000000..4649360
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.6.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.7.tgz b/PinePods-0.8.2/docs/pinepods-0.7.7.tgz
new file mode 100644
index 0000000..c6e4b25
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.7.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.7.8.tgz b/PinePods-0.8.2/docs/pinepods-0.7.8.tgz
new file mode 100644
index 0000000..7b40bcd
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.7.8.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods-0.8.1.tgz b/PinePods-0.8.2/docs/pinepods-0.8.1.tgz
new file mode 100644
index 0000000..81b0d6b
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods-0.8.1.tgz differ
diff --git a/PinePods-0.8.2/docs/pinepods.png b/PinePods-0.8.2/docs/pinepods.png
new file mode 100644
index 0000000..e218d09
Binary files /dev/null and b/PinePods-0.8.2/docs/pinepods.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..80dc00a
--- /dev/null
+++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,18 @@
+PinePods is a complete podcast management solution that allows you to host your own podcast server and enjoy a beautiful mobile experience.
+
+Features:
+
+• Self-hosted podcast server synchronization
+• Beautiful, intuitive mobile interface
+• Download episodes for offline listening
+• Chapter support with navigation
+• Playlist management
+• User statistics and listening history
+• Multi-device synchronization
+• Search and discovery
+• Background audio playback
+• Sleep timer and playback speed controls
+
+PinePods gives you complete control over your podcast experience while providing the convenience of modern podcast apps. Perfect for users who want privacy, control, and a great listening experience.
+
+Note: This app requires a PinePods server to be set up. Visit the PinePods GitHub repository for server installation instructions.
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png
new file mode 100644
index 0000000..e7aab93
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000..4fe781c
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
new file mode 100644
index 0000000..a5c21ee
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
new file mode 100644
index 0000000..668c407
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
new file mode 100644
index 0000000..6182ba2
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
new file mode 100644
index 0000000..1d45d00
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
new file mode 100644
index 0000000..4c00442
Binary files /dev/null and b/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..503b6d1
--- /dev/null
+++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+A beautiful, self-hosted podcast app with powerful server synchronization
\ No newline at end of file
diff --git a/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt b/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000..50437f6
--- /dev/null
+++ b/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+PinePods
\ No newline at end of file
diff --git a/PinePods-0.8.2/gpodder-api/cmd/server/main.go b/PinePods-0.8.2/gpodder-api/cmd/server/main.go
new file mode 100644
index 0000000..62fb1fb
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/cmd/server/main.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "pinepods/gpodder-api/config"
+ "pinepods/gpodder-api/internal/api"
+ "pinepods/gpodder-api/internal/db"
+
+ "github.com/gin-gonic/gin"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ // Load environment variables from .env file if it exists
+ _ = godotenv.Load()
+
+ // Debug log environment variables
+ fmt.Printf("Environment variables:\n")
+ fmt.Printf("DB_TYPE: %s\n", os.Getenv("DB_TYPE"))
+ fmt.Printf("DB_HOST: %s\n", os.Getenv("DB_HOST"))
+ fmt.Printf("DB_PORT: %s\n", os.Getenv("DB_PORT"))
+ fmt.Printf("DB_USER: %s\n", os.Getenv("DB_USER"))
+ fmt.Printf("DB_NAME: %s\n", os.Getenv("DB_NAME"))
+ fmt.Printf("DB_PASSWORD: [hidden]\n")
+
+ // Initialize configuration
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("Failed to load configuration: %v", err)
+ }
+
+ fmt.Printf("Using database type: %s\n", cfg.Database.Type)
+
+ // Initialize database - use Database type instead of PostgresDB
+ var database *db.Database
+ database, err = db.NewDatabase(cfg.Database)
+ if err != nil {
+ log.Fatalf("Failed to connect to database: %v", err)
+ }
+ defer database.Close()
+
+ // Set Gin mode
+ if cfg.Environment == "production" {
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ // Initialize router
+ router := gin.Default()
+
+ // Setup middleware
+ router.Use(gin.Recovery())
+ router.Use(gin.Logger())
+
+ // Add CORS middleware
+ router.Use(func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(204)
+ return
+ }
+ c.Next()
+ })
+
+ // Register API routes
+ apiRoutes := router.Group("/api/2")
+ api.RegisterRoutes(apiRoutes, database)
+
+ // Register simple API routes (v1)
+ simpleAPIRoutes := router.Group("")
+ api.RegisterSimpleRoutes(simpleAPIRoutes, database)
+
+ // Start server
+ srv := &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.Server.Port),
+ Handler: router,
+ }
+
+ // Graceful shutdown
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("Failed to start server: %v", err)
+ }
+ }()
+
+ // Wait for interrupt signal
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+ log.Println("Shutting down server...")
+
+ // Set timeout for shutdown
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ log.Fatalf("Server forced to shutdown: %v", err)
+ }
+
+ log.Println("Server exiting")
+}
diff --git a/PinePods-0.8.2/gpodder-api/config/config.go b/PinePods-0.8.2/gpodder-api/config/config.go
new file mode 100644
index 0000000..bf159e8
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/config/config.go
@@ -0,0 +1,93 @@
+package config
+
+import (
+ "os"
+ "strconv"
+)
+
+// Config represents the application configuration
+type Config struct {
+ Server ServerConfig
+ Database DatabaseConfig
+ Environment string
+}
+
+// ServerConfig holds server-related configuration
+type ServerConfig struct {
+ Port int
+}
+
+// DatabaseConfig holds database-related configuration
+type DatabaseConfig struct {
+ Host string
+ Port int
+ User string
+ Password string
+ DBName string
+ SSLMode string
+ Type string // "postgresql" or "mysql"
+}
+
+// Load loads configuration from environment variables
+func Load() (*Config, error) {
+ // Server configuration
+ serverPort, err := strconv.Atoi(getEnv("SERVER_PORT", "8080"))
+ if err != nil {
+ serverPort = 8080
+ }
+
+ // Database configuration - use DB_* environment variables
+ dbPort, err := strconv.Atoi(getEnv("DB_PORT", "5432"))
+ if err != nil {
+ dbPort = 5432
+ }
+
+ // Get database type - defaults to postgresql if not specified
+ dbType := getEnv("DB_TYPE", "postgresql")
+
+ // Set default port based on database type if not explicitly provided
+ if os.Getenv("DB_PORT") == "" {
+ if dbType == "mysql" {
+ dbPort = 3306
+ } else {
+ dbPort = 5432
+ }
+ }
+
+ // Set default user based on database type if not explicitly provided
+ dbUser := getEnv("DB_USER", "")
+ if dbUser == "" {
+ if dbType == "mysql" {
+ // Use root user for MySQL by default
+ dbUser = "root"
+ } else {
+ // Use postgres user for PostgreSQL by default
+ dbUser = "postgres"
+ }
+ }
+
+ return &Config{
+ Server: ServerConfig{
+ Port: serverPort,
+ },
+ Database: DatabaseConfig{
+ Host: getEnv("DB_HOST", "localhost"),
+ Port: dbPort,
+ User: dbUser,
+ Password: getEnv("DB_PASSWORD", "password"),
+ DBName: getEnv("DB_NAME", "pinepods_database"),
+ SSLMode: getEnv("DB_SSL_MODE", "disable"),
+ Type: dbType,
+ },
+ Environment: getEnv("ENVIRONMENT", "development"),
+ }, nil
+}
+
+// getEnv gets an environment variable or returns a default value
+func getEnv(key, defaultValue string) string {
+ value, exists := os.LookupEnv(key)
+ if !exists {
+ return defaultValue
+ }
+ return value
+}
diff --git a/PinePods-0.8.2/gpodder-api/go.mod b/PinePods-0.8.2/gpodder-api/go.mod
new file mode 100644
index 0000000..d931701
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/go.mod
@@ -0,0 +1,46 @@
+module pinepods/gpodder-api
+
+go 1.24
+
+require (
+ github.com/alexedwards/argon2id v1.0.0
+ github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611
+ github.com/gin-gonic/gin v1.10.0
+ github.com/go-sql-driver/mysql v1.9.2
+ github.com/joho/godotenv v1.5.1
+ github.com/lib/pq v1.10.9
+ github.com/mmcdole/gofeed v1.3.0
+)
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/PuerkitoBio/goquery v1.10.3 // indirect
+ github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/bytedance/sonic v1.13.2 // indirect
+ github.com/bytedance/sonic/loader v0.2.4 // indirect
+ github.com/cloudwego/base64x v0.1.5 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.9 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.26.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.10 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mmcdole/goxpp v1.1.1 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ golang.org/x/arch v0.16.0 // indirect
+ golang.org/x/crypto v0.37.0 // indirect
+ golang.org/x/net v0.39.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/PinePods-0.8.2/gpodder-api/go.sum b/PinePods-0.8.2/gpodder-api/go.sum
new file mode 100644
index 0000000..0a39cce
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/go.sum
@@ -0,0 +1,170 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
+github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
+github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
+github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
+github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
+github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
+github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
+github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
+github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
+github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM=
+github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ=
+github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
+github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
+github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
+github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
+github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
+github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
+github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
+golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
diff --git a/PinePods-0.8.2/gpodder-api/internal/api/auth.go b/PinePods-0.8.2/gpodder-api/internal/api/auth.go
new file mode 100644
index 0000000..93bffa1
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/internal/api/auth.go
@@ -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
+}
diff --git a/PinePods-0.8.2/gpodder-api/internal/api/device.go b/PinePods-0.8.2/gpodder-api/internal/api/device.go
new file mode 100644
index 0000000..8850248
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/internal/api/device.go
@@ -0,0 +1,1039 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "pinepods/gpodder-api/internal/db"
+ "pinepods/gpodder-api/internal/models"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ValidDeviceTypes contains the allowed device types according to the gpodder API
+var ValidDeviceTypes = map[string]bool{
+ "desktop": true,
+ "laptop": true,
+ "mobile": true,
+ "server": true,
+ "other": true,
+}
+
+func listDevices(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ log.Printf("[DEBUG] listDevices handling request: %s %s", c.Request.Method, c.Request.URL.Path)
+
+ // Log headers for debugging
+ headers := c.Request.Header
+ for name, values := range headers {
+ for _, value := range values {
+ log.Printf("[DEBUG] Header: %s: %s", name, value)
+ }
+ }
+
+ // Log cookies
+ cookies := c.Request.Cookies()
+ for _, cookie := range cookies {
+ log.Printf("[DEBUG] Cookie: %s: %s", cookie.Name, cookie.Value)
+ }
+
+ // Get user ID from context
+ userID, exists := c.Get("userID")
+ if !exists {
+ log.Printf("[ERROR] listDevices: userID not found in context")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ // Fix for the listDevices function
+ // Replace the query code in listDevices function with this:
+
+ log.Printf("[DEBUG] listDevices: Querying devices for userID: %v", userID)
+
+ var query string
+ var rows *sql.Rows
+ var err error
+
+ // Format query according to database type
+ if database.IsPostgreSQLDB() {
+ query = `
+ SELECT d.DeviceID, d.DeviceName, d.DeviceType,
+ COALESCE(d.DeviceCaption, '') as DeviceCaption, d.IsActive,
+ COALESCE(
+ (SELECT COUNT(p.PodcastID)
+ FROM "Podcasts" p
+ WHERE p.UserID = $1),
+ 0
+ ) as subscription_count
+ FROM "GpodderDevices" d
+ WHERE d.UserID = $1 AND d.IsActive = true
+ `
+ rows, err = database.Query(query, userID)
+ } else {
+ query = `
+ SELECT d.DeviceID, d.DeviceName, d.DeviceType,
+ COALESCE(d.DeviceCaption, '') as DeviceCaption, d.IsActive,
+ COALESCE(
+ (SELECT COUNT(p.PodcastID)
+ FROM Podcasts p
+ WHERE p.UserID = ?),
+ 0
+ ) as subscription_count
+ FROM GpodderDevices d
+ WHERE d.UserID = ? AND d.IsActive = true
+ `
+ rows, err = database.Query(query, userID, userID) // Note: passing userID twice for MySQL
+ }
+
+ if err != nil {
+ log.Printf("[ERROR] listDevices: Error querying devices: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get devices"})
+ return
+ }
+ defer rows.Close()
+
+ var devices []models.GpodderDevice
+ for rows.Next() {
+ var device models.GpodderDevice
+ var isActive bool
+
+ if err := rows.Scan(
+ &device.DeviceID,
+ &device.DeviceName,
+ &device.DeviceType,
+ &device.DeviceCaption,
+ &isActive,
+ &device.Subscriptions,
+ ); err != nil {
+ log.Printf("[ERROR] listDevices: Error scanning device row: %v", err)
+ continue // Continue instead of returning to try to get at least some devices
+ }
+
+ // Only add active devices
+ if isActive {
+ log.Printf("[DEBUG] listDevices: Found active device: %s (ID: %d)",
+ device.DeviceName, device.DeviceID)
+ devices = append(devices, device)
+ }
+ }
+
+ if err := rows.Err(); err != nil {
+ log.Printf("[ERROR] listDevices: Error iterating device rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get devices"})
+ return
+ }
+
+ // If no devices found, return empty array rather than error
+ if len(devices) == 0 {
+ log.Printf("[DEBUG] listDevices: No devices found for userID: %v", userID)
+ c.JSON(http.StatusOK, []models.GpodderDevice{})
+ return
+ }
+
+ log.Printf("[DEBUG] listDevices: Returning %d devices for userID: %v", len(devices), userID)
+
+ // Return the list of devices
+ c.JSON(http.StatusOK, devices)
+ }
+}
+
+// updateDeviceData handles POST /api/2/devices/{username}/{deviceid}.json
+func updateDeviceData(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ log.Printf("[DEBUG] updateDeviceData handling request: %s %s", c.Request.Method, c.Request.URL.Path)
+
+ // Get user ID from context (set by AuthMiddleware)
+ userID, exists := c.Get("userID")
+ if !exists {
+ log.Printf("[ERROR] updateDeviceData: userID not found in context")
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ // Get device name from URL with fix for .json suffix
+ deviceName := c.Param("deviceid")
+ // Also try alternative parameter name if needed
+ if deviceName == "" {
+ deviceName = c.Param("deviceid.json")
+ }
+
+ // Remove .json suffix if present
+ if strings.HasSuffix(deviceName, ".json") {
+ deviceName = strings.TrimSuffix(deviceName, ".json")
+ }
+
+ log.Printf("[DEBUG] updateDeviceData: Using device name: '%s'", deviceName)
+
+ if deviceName == "" {
+ log.Printf("[ERROR] updateDeviceData: Device ID is required")
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"})
+ return
+ }
+
+ // Parse request body
+ var req struct {
+ Caption string `json:"caption"`
+ Type string `json:"type"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error parsing request body: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'caption' and 'type'"})
+ return
+ }
+
+ log.Printf("[DEBUG] updateDeviceData: Device info - Name: %s, Caption: %s, Type: %s",
+ deviceName, req.Caption, req.Type)
+
+ // Validate device type if provided
+ if req.Type != "" && !ValidDeviceTypes[req.Type] {
+ log.Printf("[ERROR] updateDeviceData: Invalid device type: %s", req.Type)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Invalid device type: %s. Valid types are: desktop, laptop, mobile, server, other", req.Type),
+ })
+ return
+ }
+
+ // If type is empty, set to default 'other'
+ if req.Type == "" {
+ req.Type = "other"
+ }
+
+ // Begin transaction
+ tx, err := database.Begin()
+ if err != nil {
+ log.Printf("[ERROR] updateDeviceData: 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 device exists
+ var deviceID int
+ var query string
+
+ log.Printf("[DEBUG] updateDeviceData: Checking if device exists - UserID: %v, DeviceName: %s", userID, deviceName)
+
+ 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, deviceName).Scan(&deviceID)
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ // Device doesn't exist, create it
+ log.Printf("[DEBUG] updateDeviceData: Creating new device - UserID: %v, DeviceName: %s, Type: %s",
+ userID, deviceName, req.Type)
+
+ if database.IsPostgreSQLDB() {
+ query = `INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, DeviceCaption, IsActive, LastSync)
+ VALUES ($1, $2, $3, $4, true, $5) RETURNING DeviceID`
+
+ err = tx.QueryRow(query, userID, deviceName, req.Type, req.Caption, time.Now()).Scan(&deviceID)
+ if err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error creating device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
+ return
+ }
+ } else {
+ query = `INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, DeviceCaption, IsActive, LastSync)
+ VALUES (?, ?, ?, ?, true, ?)`
+
+ result, err := tx.Exec(query, userID, deviceName, req.Type, req.Caption, time.Now())
+ if err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error creating device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
+ return
+ }
+
+ // Get the last inserted ID
+ lastID, err := result.LastInsertId()
+ if err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error getting last insert ID: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device ID"})
+ return
+ }
+
+ deviceID = int(lastID)
+ }
+
+ log.Printf("[DEBUG] updateDeviceData: Created new device with ID: %d", deviceID)
+
+ // Also create entry in device state table, handling both PostgreSQL and MySQL syntax
+ if database.IsPostgreSQLDB() {
+ query = `INSERT INTO "GpodderSyncDeviceState" (UserID, DeviceID)
+ VALUES ($1, $2) ON CONFLICT (UserID, DeviceID) DO NOTHING`
+ _, err = tx.Exec(query, userID, deviceID)
+ } else {
+ // In MySQL, use INSERT IGNORE instead of ON CONFLICT
+ query = `INSERT IGNORE INTO GpodderSyncDeviceState (UserID, DeviceID) VALUES (?, ?)`
+ _, err = tx.Exec(query, userID, deviceID)
+ }
+
+ // Log the device state creation result but don't fail on error
+ if err != nil {
+ log.Printf("[WARNING] updateDeviceData: Error creating device state: %v", err)
+ // Not fatal, continue with response
+ }
+ } else {
+ log.Printf("[ERROR] updateDeviceData: Error checking device existence: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"})
+ return
+ }
+ } else {
+ // Device exists, update it
+ log.Printf("[DEBUG] updateDeviceData: Updating existing device with ID: %d", deviceID)
+
+ if database.IsPostgreSQLDB() {
+ query = `UPDATE "GpodderDevices" SET DeviceType = $1, DeviceCaption = $2, LastSync = $3, IsActive = true
+ WHERE DeviceID = $4`
+ } else {
+ query = `UPDATE GpodderDevices SET DeviceType = ?, DeviceCaption = ?, LastSync = ?, IsActive = true
+ WHERE DeviceID = ?`
+ }
+
+ _, err = tx.Exec(query, req.Type, req.Caption, time.Now(), deviceID)
+
+ if err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error updating device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"})
+ return
+ }
+ }
+
+ // Commit transaction
+ if err = tx.Commit(); err != nil {
+ log.Printf("[ERROR] updateDeviceData: Error committing transaction: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
+ return
+ }
+
+ // Return empty response with 200 status code as per gpodder API
+ log.Printf("[DEBUG] updateDeviceData: Successfully processed device request")
+ c.JSON(http.StatusOK, gin.H{})
+ }
+}
+
+// getDeviceUpdates handles GET /api/2/updates/{username}/{deviceid}.json
+func getDeviceUpdates(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ log.Printf("[DEBUG] getDeviceUpdates: Processing request: %s %s",
+ c.Request.Method, c.Request.URL.Path)
+ // 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 device name from URL with fix for .json suffix
+ deviceName := c.Param("deviceid")
+ // Also try alternative parameter name if needed
+ if deviceName == "" {
+ deviceName = c.Param("deviceid.json")
+ }
+
+ // Remove .json suffix if present
+ if strings.HasSuffix(deviceName, ".json") {
+ deviceName = strings.TrimSuffix(deviceName, ".json")
+ }
+
+ log.Printf("[DEBUG] getDeviceUpdates: Using device name: '%s'", deviceName)
+
+ // Parse query parameters
+ sinceStr := c.Query("since")
+ includeActions := c.Query("include_actions") == "true"
+
+ var since int64 = 0
+ if sinceStr != "" {
+ _, err := fmt.Sscanf(sinceStr, "%d", &since)
+ if err != nil {
+ log.Printf("Invalid 'since' parameter: %s - %v", sinceStr, err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'since' parameter: must be a Unix timestamp"})
+ 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 or create the device
+ var deviceID 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 = tx.QueryRow(query, userID, deviceName).Scan(&deviceID)
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ // Device doesn't exist or is inactive, create it
+ log.Printf("Creating new device for updates: %s", deviceName)
+
+ if database.IsPostgreSQLDB() {
+ query = `
+ INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
+ VALUES ($1, $2, 'other', true, $3)
+ RETURNING DeviceID
+ `
+ err = tx.QueryRow(query, userID, deviceName, time.Now()).Scan(&deviceID)
+ } else {
+ query = `
+ INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
+ VALUES (?, ?, 'other', true, ?)
+ `
+ result, err := tx.Exec(query, userID, deviceName, 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 get device ID"})
+ return
+ }
+
+ deviceID = int(lastID)
+ }
+
+ if err != nil {
+ log.Printf("Error creating device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
+ return
+ }
+
+ // Also create entry in device state table
+ if database.IsPostgreSQLDB() {
+ query = `
+ INSERT INTO "GpodderSyncDeviceState" (UserID, DeviceID)
+ VALUES ($1, $2)
+ ON CONFLICT (UserID, DeviceID) DO NOTHING
+ `
+ _, err = tx.Exec(query, userID, deviceID)
+ } else {
+ query = `
+ INSERT IGNORE INTO GpodderSyncDeviceState (UserID, DeviceID)
+ VALUES (?, ?)
+ `
+ _, err = tx.Exec(query, userID, deviceID)
+ }
+
+ if err != nil {
+ log.Printf("Error creating device state: %v", err)
+ // Not fatal, continue
+ }
+ } else {
+ log.Printf("Error getting device ID: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
+ return
+ }
+ }
+
+ // Get the current timestamp for the response
+ timestamp := time.Now().Unix()
+
+ // Build the response structure
+ response := models.DeviceUpdateResponse{
+ Add: []models.Podcast{},
+ Remove: []string{},
+ Updates: []models.Episode{},
+ Timestamp: timestamp,
+ }
+
+ // Only process updates if a since timestamp was provided
+ if since > 0 {
+ // Get the last sync timestamp for this device
+ var lastSync int64
+
+ if database.IsPostgreSQLDB() {
+ query = `
+ SELECT COALESCE(LastTimestamp, 0)
+ FROM "GpodderSyncState"
+ WHERE UserID = $1 AND DeviceID = $2
+ `
+ } else {
+ query = `
+ SELECT COALESCE(LastTimestamp, 0)
+ FROM GpodderSyncState
+ WHERE UserID = ? AND DeviceID = ?
+ `
+ }
+
+ err = tx.QueryRow(query, userID, deviceID).Scan(&lastSync)
+
+ if err != nil && err != sql.ErrNoRows {
+ log.Printf("Error getting last sync timestamp: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sync state"})
+ return
+ }
+
+ // Handle podcasts to add (subscribed on other devices since the timestamp)
+ var addRows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ query = `
+ SELECT DISTINCT p.FeedURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL,
+ (SELECT COUNT(*) FROM "Podcasts" WHERE FeedURL = p.FeedURL) as subscribers
+ FROM "Podcasts" p
+ JOIN "GpodderSyncSubscriptions" s ON p.FeedURL = s.PodcastURL
+ WHERE s.UserID = $1
+ AND s.DeviceID != $2
+ AND s.Timestamp > $3
+ AND s.Action = 'add'
+ AND NOT EXISTS (
+ SELECT 1 FROM "GpodderSyncSubscriptions" s2
+ WHERE s2.UserID = s.UserID
+ AND s2.PodcastURL = s.PodcastURL
+ AND s2.DeviceID = $2
+ AND s2.Timestamp > s.Timestamp
+ AND s2.Action = 'add'
+ )
+ `
+ addRows, err = tx.Query(query, userID, deviceID, since)
+ } else {
+ query = `
+ SELECT DISTINCT p.FeedURL, p.PodcastName, p.Description, p.Author, p.ArtworkURL, p.WebsiteURL,
+ (SELECT COUNT(*) FROM Podcasts WHERE FeedURL = p.FeedURL) as subscribers
+ FROM Podcasts p
+ JOIN GpodderSyncSubscriptions s ON p.FeedURL = s.PodcastURL
+ WHERE s.UserID = ?
+ AND s.DeviceID != ?
+ AND s.Timestamp > ?
+ AND s.Action = 'add'
+ AND NOT EXISTS (
+ SELECT 1 FROM GpodderSyncSubscriptions s2
+ WHERE s2.UserID = s.UserID
+ AND s2.PodcastURL = s.PodcastURL
+ AND s2.DeviceID = ?
+ AND s2.Timestamp > s.Timestamp
+ AND s2.Action = 'add'
+ )
+ `
+ addRows, err = tx.Query(query, userID, deviceID, since, deviceID) // Note: deviceID is used twice in MySQL
+ }
+
+ if err != nil {
+ log.Printf("Error getting podcasts to add: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updates"})
+ return
+ }
+ defer addRows.Close()
+
+ for addRows.Next() {
+ var podcast models.Podcast
+ var podcastName, description, author, artworkURL, websiteURL sql.NullString
+ var subscribers int
+
+ err := addRows.Scan(
+ &podcast.URL,
+ &podcastName,
+ &description,
+ &author,
+ &artworkURL,
+ &websiteURL,
+ &subscribers,
+ )
+ if err != nil {
+ log.Printf("Error scanning podcast row: %v", err)
+ continue
+ }
+
+ // Set title - default to URL if name is null
+ if podcastName.Valid && podcastName.String != "" {
+ podcast.Title = podcastName.String
+ } else {
+ podcast.Title = podcast.URL
+ }
+
+ // Set optional fields if present
+ 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
+ }
+
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%s", podcast.URL)
+
+ // Add the podcast to the response
+ response.Add = append(response.Add, podcast)
+ }
+
+ if err = addRows.Err(); err != nil {
+ log.Printf("Error iterating add rows: %v", err)
+ // Continue processing other updates
+ }
+
+ // Query podcasts to remove (unsubscribed on other devices)
+ var removeRows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ query = `
+ SELECT DISTINCT s.PodcastURL
+ FROM "GpodderSyncSubscriptions" s
+ WHERE s.UserID = $1
+ AND s.DeviceID != $2
+ AND s.Timestamp > $3
+ AND s.Action = 'remove'
+ AND NOT EXISTS (
+ SELECT 1 FROM "GpodderSyncSubscriptions" s2
+ WHERE s2.UserID = s.UserID
+ AND s2.PodcastURL = s.PodcastURL
+ AND s2.DeviceID = $2
+ AND s2.Timestamp > s.Timestamp
+ AND s2.Action = 'add'
+ )
+ `
+ removeRows, err = tx.Query(query, userID, deviceID, since)
+ } else {
+ query = `
+ SELECT DISTINCT s.PodcastURL
+ FROM GpodderSyncSubscriptions s
+ WHERE s.UserID = ?
+ AND s.DeviceID != ?
+ AND s.Timestamp > ?
+ AND s.Action = 'remove'
+ AND NOT EXISTS (
+ SELECT 1 FROM GpodderSyncSubscriptions s2
+ WHERE s2.UserID = s.UserID
+ AND s2.PodcastURL = s.PodcastURL
+ AND s2.DeviceID = ?
+ AND s2.Timestamp > s.Timestamp
+ AND s2.Action = 'add'
+ )
+ `
+ removeRows, err = tx.Query(query, userID, deviceID, since, deviceID) // Note: deviceID is used twice
+ }
+
+ if err != nil {
+ log.Printf("Error getting podcasts to remove: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updates"})
+ return
+ }
+ defer removeRows.Close()
+
+ for removeRows.Next() {
+ var podcastURL string
+ err := removeRows.Scan(&podcastURL)
+ if err != nil {
+ log.Printf("Error scanning podcast URL: %v", err)
+ continue
+ }
+
+ // Add the podcast URL to the response
+ response.Remove = append(response.Remove, podcastURL)
+ }
+
+ if err = removeRows.Err(); err != nil {
+ log.Printf("Error iterating remove rows: %v", err)
+ // Continue processing other updates
+ }
+
+ // Query episode updates (if includeActions is true)
+ if includeActions {
+ var updateRows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ query = `
+ SELECT e.EpisodeTitle, e.EpisodeURL, p.PodcastName, p.FeedURL,
+ e.EpisodeDescription, e.EpisodeURL, e.EpisodePubDate,
+ a.Action, a.Position, a.Total, a.Started
+ FROM "GpodderSyncEpisodeActions" a
+ JOIN "Episodes" e ON a.EpisodeURL = e.EpisodeURL
+ JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
+ WHERE a.UserID = $1
+ AND a.Timestamp > $2
+ AND a.Action != 'new'
+ ORDER BY a.Timestamp DESC
+ `
+ updateRows, err = tx.Query(query, userID, since)
+ } else {
+ query = `
+ SELECT e.EpisodeTitle, e.EpisodeURL, p.PodcastName, p.FeedURL,
+ e.EpisodeDescription, e.EpisodeURL, e.EpisodePubDate,
+ a.Action, a.Position, a.Total, a.Started
+ FROM GpodderSyncEpisodeActions a
+ JOIN Episodes e ON a.EpisodeURL = e.EpisodeURL
+ JOIN Podcasts p ON e.PodcastID = p.PodcastID
+ WHERE a.UserID = ?
+ AND a.Timestamp > ?
+ AND a.Action != 'new'
+ ORDER BY a.Timestamp DESC
+ `
+ updateRows, err = tx.Query(query, userID, since)
+ }
+
+ if err != nil {
+ log.Printf("Error getting episode updates: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode updates"})
+ return
+ }
+ defer updateRows.Close()
+
+ for updateRows.Next() {
+ var episode models.Episode
+ var pubDate time.Time
+ var action string
+ var position, total, started sql.NullInt64
+
+ err := updateRows.Scan(
+ &episode.Title,
+ &episode.URL,
+ &episode.PodcastTitle,
+ &episode.PodcastURL,
+ &episode.Description,
+ &episode.Website,
+ &pubDate,
+ &action,
+ &position,
+ &total,
+ &started,
+ )
+ if err != nil {
+ log.Printf("Error scanning episode row: %v", err)
+ continue
+ }
+
+ // Format the publication date in ISO 8601 format
+ episode.Released = pubDate.Format(time.RFC3339)
+
+ // Add the episode to the response
+ response.Updates = append(response.Updates, episode)
+ }
+
+ if err = updateRows.Err(); err != nil {
+ log.Printf("Error iterating episode update rows: %v", err)
+ // Continue with other processing
+ }
+ }
+ }
+
+ // Update the last sync timestamp for this device
+ if database.IsPostgreSQLDB() {
+ query = `
+ INSERT INTO "GpodderSyncState" (UserID, DeviceID, LastTimestamp)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (UserID, DeviceID)
+ DO UPDATE SET LastTimestamp = $3
+ `
+ _, err = tx.Exec(query, userID, deviceID, timestamp)
+ } else {
+ // MySQL uses INSERT ... ON DUPLICATE KEY UPDATE syntax
+ query = `
+ INSERT INTO GpodderSyncState (UserID, DeviceID, LastTimestamp)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE LastTimestamp = ?
+ `
+ _, err = tx.Exec(query, userID, deviceID, timestamp, timestamp)
+ }
+
+ if err != nil {
+ log.Printf("Error updating device sync state: %v", err)
+ // Not fatal, continue with response
+ }
+
+ // Update the device LastSync
+ if database.IsPostgreSQLDB() {
+ query = `
+ UPDATE "GpodderDevices"
+ SET LastSync = $1
+ WHERE DeviceID = $2
+ `
+ _, err = tx.Exec(query, time.Now(), deviceID)
+ } else {
+ query = `
+ UPDATE GpodderDevices
+ SET LastSync = ?
+ WHERE DeviceID = ?
+ `
+ _, err = tx.Exec(query, time.Now(), deviceID)
+ }
+
+ if err != nil {
+ log.Printf("Error updating device last sync time: %v", err)
+ // Non-critical error, continue
+ }
+
+ // 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 the response
+ c.JSON(http.StatusOK, response)
+ }
+}
+
+// deactivateDevice handles DELETE /api/2/devices/{username}/{deviceid}.json
+// This is an extension to the gpodder API for device management
+func deactivateDevice(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
+ }
+
+ // Get device name from URL
+ // Get device name from URL with fix for .json suffix
+ deviceName := c.Param("deviceid")
+ // Also try alternative parameter name if needed
+ if deviceName == "" {
+ deviceName = c.Param("deviceid.json")
+ }
+
+ // Remove .json suffix if present
+ if strings.HasSuffix(deviceName, ".json") {
+ deviceName = strings.TrimSuffix(deviceName, ".json")
+ }
+
+ log.Printf("[DEBUG] deactivateDevice: Using device name: '%s'", deviceName)
+
+ // Get the device ID
+ var deviceID int
+ err := database.QueryRow(`
+ SELECT DeviceID FROM "GpodderDevices"
+ WHERE UserID = $1 AND DeviceName = $2
+ `, userID, deviceName).Scan(&deviceID)
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
+ } else {
+ log.Printf("Error getting device ID: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
+ }
+ return
+ }
+
+ // Deactivate the device (rather than delete, to preserve history)
+ _, err = database.Exec(`
+ UPDATE "GpodderDevices"
+ SET IsActive = false
+ WHERE DeviceID = $1
+ `, deviceID)
+
+ if err != nil {
+ log.Printf("Error deactivating device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate device"})
+ return
+ }
+
+ // Return success
+ c.JSON(http.StatusOK, gin.H{
+ "result": "success",
+ "message": "Device deactivated",
+ })
+ }
+}
+
+// renameDevice handles PUT /api/2/devices/{username}/{deviceid}/rename.json
+// This is an extension to the gpodder API for device management
+func renameDevice(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
+ }
+
+ // Get device name from URL
+ oldDeviceName := c.Param("deviceid")
+ if oldDeviceName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"})
+ return
+ }
+
+ // Parse request body
+ var req struct {
+ NewDeviceName string `json:"new_deviceid"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'new_deviceid'"})
+ return
+ }
+
+ if req.NewDeviceName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "New device ID is required"})
+ return
+ }
+
+ // Check if the new device name already exists
+ var existingCount int
+ err := database.QueryRow(`
+ SELECT COUNT(*) FROM "GpodderDevices"
+ WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
+ `, userID, req.NewDeviceName).Scan(&existingCount)
+
+ if err != nil {
+ log.Printf("Error checking for existing device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for existing device"})
+ return
+ }
+
+ if existingCount > 0 {
+ c.JSON(http.StatusConflict, gin.H{"error": "Device with this name already exists"})
+ return
+ }
+
+ // Update the device name
+ result, err := database.Exec(`
+ UPDATE "GpodderDevices"
+ SET DeviceName = $1, LastSync = $2
+ WHERE UserID = $3 AND DeviceName = $4 AND IsActive = true
+ `, req.NewDeviceName, time.Now(), userID, oldDeviceName)
+
+ if err != nil {
+ log.Printf("Error renaming device: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename device"})
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ log.Printf("Error getting rows affected: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get operation result"})
+ return
+ }
+
+ if rowsAffected == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Device not found or not active"})
+ return
+ }
+
+ // Return success
+ c.JSON(http.StatusOK, gin.H{
+ "result": "success",
+ "message": "Device renamed successfully",
+ })
+ }
+}
+
+// deviceSync represents the synchronization state of a device
+type deviceSync struct {
+ LastSync time.Time `json:"last_sync"`
+ DeviceID int `json:"-"`
+ DeviceName string `json:"device_id"`
+ DeviceType string `json:"device_type"`
+ IsActive bool `json:"-"`
+ SyncEnabled bool `json:"sync_enabled"`
+}
+
+// getDeviceSyncStatus handles GET /api/2/devices/{username}/sync.json
+// This is an extension to the gpodder API for device sync status
+func getDeviceSyncStatus(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
+ }
+
+ // Query all devices and their sync status
+ rows, err := database.Query(`
+ SELECT d.DeviceID, d.DeviceName, d.DeviceType, d.LastSync, d.IsActive,
+ EXISTS (
+ SELECT 1 FROM "GpodderSyncDevicePairs" p
+ WHERE (p.DeviceID1 = d.DeviceID OR p.DeviceID2 = d.DeviceID)
+ AND p.UserID = d.UserID
+ ) as sync_enabled
+ FROM "GpodderDevices" d
+ WHERE d.UserID = $1
+ ORDER BY d.LastSync DESC
+ `, userID)
+
+ if err != nil {
+ log.Printf("Error querying device sync status: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device sync status"})
+ return
+ }
+ defer rows.Close()
+
+ devices := make([]deviceSync, 0)
+ for rows.Next() {
+ var device deviceSync
+ var lastSync sql.NullTime
+
+ if err := rows.Scan(
+ &device.DeviceID,
+ &device.DeviceName,
+ &device.DeviceType,
+ &lastSync,
+ &device.IsActive,
+ &device.SyncEnabled,
+ ); err != nil {
+ log.Printf("Error scanning device sync row: %v", err)
+ continue
+ }
+
+ // Set the last sync time if valid
+ if lastSync.Valid {
+ device.LastSync = lastSync.Time
+ }
+
+ // Only include active devices
+ if device.IsActive {
+ devices = append(devices, device)
+ }
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating device sync rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process device sync status"})
+ return
+ }
+
+ // Return the response
+ c.JSON(http.StatusOK, gin.H{
+ "devices": devices,
+ "timestamp": time.Now().Unix(),
+ })
+ }
+}
diff --git a/PinePods-0.8.2/gpodder-api/internal/api/directory.go b/PinePods-0.8.2/gpodder-api/internal/api/directory.go
new file mode 100644
index 0000000..941e307
--- /dev/null
+++ b/PinePods-0.8.2/gpodder-api/internal/api/directory.go
@@ -0,0 +1,1117 @@
+package api
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "pinepods/gpodder-api/internal/db"
+ "pinepods/gpodder-api/internal/models"
+
+ "github.com/gin-gonic/gin"
+)
+
+// Maximum number of items to return in listings
+const MAX_DIRECTORY_ITEMS = 100
+
+// Common tag categories for podcasts
+var commonCategories = []models.Tag{
+ {Title: "Technology", Tag: "technology", Usage: 530},
+ {Title: "Society & Culture", Tag: "society-culture", Usage: 420},
+ {Title: "Arts", Tag: "arts", Usage: 400},
+ {Title: "News & Politics", Tag: "news-politics", Usage: 320},
+ {Title: "Business", Tag: "business", Usage: 300},
+ {Title: "Education", Tag: "education", Usage: 280},
+ {Title: "Science", Tag: "science", Usage: 260},
+ {Title: "Comedy", Tag: "comedy", Usage: 240},
+ {Title: "Health", Tag: "health", Usage: 220},
+ {Title: "Sports", Tag: "sports", Usage: 200},
+ {Title: "History", Tag: "history", Usage: 180},
+ {Title: "Religion & Spirituality", Tag: "religion-spirituality", Usage: 160},
+ {Title: "TV & Film", Tag: "tv-film", Usage: 140},
+ {Title: "Music", Tag: "music", Usage: 120},
+ {Title: "Games & Hobbies", Tag: "games-hobbies", Usage: 100},
+}
+
+// getTopTags handles GET /api/2/tags/{count}.json
+func getTopTags(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Parse count parameter
+ countStr := c.Param("count")
+ count, err := strconv.Atoi(countStr)
+ if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)})
+ return
+ }
+
+ var rows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ // PostgreSQL specific query using array functions
+ rows, err = database.Query(`
+ WITH category_counts AS (
+ SELECT
+ unnest(string_to_array(Categories, ',')) as category,
+ COUNT(*) as usage
+ FROM "Podcasts"
+ WHERE Categories IS NOT NULL AND Categories != ''
+ GROUP BY category
+ )
+ SELECT
+ category as tag,
+ category as title,
+ usage
+ FROM category_counts
+ ORDER BY usage DESC
+ LIMIT $1
+ `, count)
+ } else {
+ // MySQL equivalent - need to use different approach since MySQL doesn't have unnest
+ // Using FIND_IN_SET with a subquery for each common category
+ // This is a simplified approach - in a real implementation you might want to
+ // use a more sophisticated method for MySQL to extract and count categories
+ placeholders := make([]string, len(commonCategories))
+ args := make([]interface{}, len(commonCategories)+1)
+ args[0] = count // First arg is the LIMIT parameter
+
+ for i, category := range commonCategories {
+ placeholders[i] = fmt.Sprintf(`
+ SELECT
+ ?,
+ ?,
+ COUNT(*) as usage
+ FROM Podcasts
+ WHERE Categories IS NOT NULL AND FIND_IN_SET(?, Categories) > 0
+ `)
+ args[i+1] = category.Tag
+ // In a real implementation, we would add more parameters here
+ }
+
+ // In a real implementation, this query would be more sophisticated
+ // For now, we'll just return results from the commonCategories slice
+ // and limit it by count
+ rows = nil
+ err = fmt.Errorf("MySQL implementation falls back to default categories")
+ }
+
+ // If query fails or returns no rows, use the default list
+ if err != nil || rows == nil {
+ log.Printf("Error querying categories, using default list: %v", err)
+ result := commonCategories
+ if len(result) > count {
+ result = result[:count]
+ }
+ c.JSON(http.StatusOK, result)
+ return
+ }
+ defer rows.Close()
+
+ // Process database results
+ tags := make([]models.Tag, 0, count)
+ for rows.Next() {
+ var tag models.Tag
+ if err := rows.Scan(&tag.Tag, &tag.Title, &tag.Usage); err != nil {
+ log.Printf("Error scanning tag row: %v", err)
+ continue
+ }
+ // Clean the tag
+ tag.Tag = strings.ToLower(strings.TrimSpace(tag.Tag))
+ tag.Tag = strings.ReplaceAll(tag.Tag, " ", "-")
+ // Format the title properly
+ tag.Title = formatTagTitle(tag.Tag)
+ tags = append(tags, tag)
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating tag rows: %v", err)
+ }
+
+ // If we got no results from the database, use the default list
+ if len(tags) == 0 {
+ result := commonCategories
+ if len(result) > count {
+ result = result[:count]
+ }
+ c.JSON(http.StatusOK, result)
+ return
+ }
+
+ c.JSON(http.StatusOK, tags)
+ }
+}
+
+// formatTagTitle formats a tag string into a proper title
+func formatTagTitle(tag string) string {
+ // Replace hyphens with spaces
+ title := strings.ReplaceAll(tag, "-", " ")
+
+ // Convert to title case (capitalize first letter of each word)
+ words := strings.Fields(title)
+ for i, word := range words {
+ if len(word) > 0 {
+ words[i] = strings.ToUpper(word[:1]) + word[1:]
+ }
+ }
+
+ return strings.Join(words, " ")
+}
+
+// getPodcastsForTag handles GET /api/2/tag/{tag}/{count}.json
+func getPodcastsForTag(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Parse parameters
+ tag := c.Param("tag")
+ countStr := c.Param("count")
+ count, err := strconv.Atoi(countStr)
+ if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)})
+ return
+ }
+
+ // Format tag for searching
+ searchTag := "%" + strings.ReplaceAll(tag, "-", " ") + "%"
+
+ // Query podcasts with the given tag
+ var rows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ // PostgreSQL query with DISTINCT ON
+ rows, err = database.Query(`
+ SELECT DISTINCT ON (p.PodcastID)
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) OVER (PARTITION BY p.PodcastID) as subscribers
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ WHERE
+ p.Categories ILIKE $1 OR
+ p.PodcastName ILIKE $1 OR
+ p.Description ILIKE $1
+ ORDER BY p.PodcastID, subscribers DESC
+ LIMIT $2
+ `, searchTag, count)
+ } else {
+ // MySQL equivalent without DISTINCT ON and window functions
+ rows, err = database.Query(`
+ SELECT
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ WHERE
+ p.Categories LIKE ? OR
+ p.PodcastName LIKE ? OR
+ p.Description LIKE ?
+ GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL
+ ORDER BY subscribers DESC
+ LIMIT ?
+ `, searchTag, searchTag, searchTag, count)
+ }
+
+ if err != nil {
+ log.Printf("Error querying podcasts by tag: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcasts for tag"})
+ return
+ }
+ defer rows.Close()
+
+ // Build podcast list
+ podcasts := make([]models.Podcast, 0)
+ for rows.Next() {
+ var podcast models.Podcast
+ var podcastID int
+ var author, description, websiteURL, artworkURL sql.NullString
+ var subscribers int
+ if err := rows.Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ ); err != nil {
+ log.Printf("Error scanning podcast: %v", err)
+ continue
+ }
+ // Set optional fields if present
+ if author.Valid {
+ podcast.Author = author.String
+ }
+ if description.Valid {
+ podcast.Description = description.String
+ }
+ if websiteURL.Valid {
+ podcast.Website = websiteURL.String
+ }
+ if artworkURL.Valid {
+ podcast.LogoURL = artworkURL.String
+ }
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID)
+ podcasts = append(podcasts, podcast)
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating podcast rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process podcasts"})
+ return
+ }
+
+ c.JSON(http.StatusOK, podcasts)
+ }
+}
+
+// getPodcastData handles GET /api/2/data/podcast.json
+func getPodcastData(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get podcast URL from query parameter
+ podcastURL := c.Query("url")
+ if podcastURL == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "URL parameter is required"})
+ return
+ }
+
+ // Query podcast data
+ var podcast models.Podcast
+ var podcastID int
+ var author, description, websiteURL, artworkURL sql.NullString
+ var subscribers int
+
+ var err error
+
+ if database.IsPostgreSQLDB() {
+ // PostgreSQL query
+ err = database.QueryRow(`
+ SELECT
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ WHERE p.FeedURL = $1
+ GROUP BY p.PodcastID
+ LIMIT 1
+ `, podcastURL).Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ )
+ } else {
+ // MySQL query
+ err = database.QueryRow(`
+ SELECT
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ WHERE p.FeedURL = ?
+ GROUP BY p.PodcastID
+ LIMIT 1
+ `, podcastURL).Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ )
+ }
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Podcast not found"})
+ } else {
+ log.Printf("Error querying podcast data: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get podcast data"})
+ }
+ return
+ }
+
+ // Set optional fields if present
+ if author.Valid {
+ podcast.Author = author.String
+ }
+ if description.Valid {
+ podcast.Description = description.String
+ }
+ if websiteURL.Valid {
+ podcast.Website = websiteURL.String
+ }
+ if artworkURL.Valid {
+ podcast.LogoURL = artworkURL.String
+ }
+
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID)
+
+ c.JSON(http.StatusOK, podcast)
+ }
+}
+
+// isValidCallbackName checks if a JSONP callback name is valid and safe
+func isValidCallbackName(callback string) bool {
+ // Only allow alphanumeric characters, underscore, and period in callback names
+ validCallbackRegex := regexp.MustCompile(`^[a-zA-Z0-9_.]+$`)
+ return validCallbackRegex.MatchString(callback)
+}
+
+// podcastSearch handles GET /search.{format}
+func podcastSearch(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get query parameter
+ query := c.Query("q")
+ if query == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
+ return
+ }
+
+ // Get format parameter
+ format := c.Param("format")
+ if format == "" {
+ format = "json" // Default format
+ }
+
+ // Parse optional parameters
+ scaleLogo := c.Query("scale_logo")
+ var scaleSize int
+ if scaleLogo != "" {
+ size, err := strconv.Atoi(scaleLogo)
+ if err != nil || size < 1 || size > 256 {
+ scaleSize = 64 // Default size
+ } else {
+ scaleSize = size
+ }
+ }
+
+ // Limit search terms to prevent performance issues
+ if len(query) > 100 {
+ query = query[:100]
+ }
+
+ // Prepare search query terms for SQL
+ searchTerms := "%" + strings.ReplaceAll(query, " ", "%") + "%"
+
+ // Search podcasts
+ var rows *sql.Rows
+ var err error
+
+ if database.IsPostgreSQLDB() {
+ // PostgreSQL query with DISTINCT ON and window functions
+ rows, err = database.Query(`
+ SELECT DISTINCT ON (p.PodcastID)
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) OVER (PARTITION BY p.PodcastID) as subscribers,
+ CASE
+ WHEN p.PodcastName ILIKE $1 THEN 1
+ WHEN p.Author ILIKE $1 THEN 2
+ WHEN p.Description ILIKE $1 THEN 3
+ ELSE 4
+ END as match_priority
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ WHERE
+ p.PodcastName ILIKE $1 OR
+ p.Author ILIKE $1 OR
+ p.Description ILIKE $1
+ ORDER BY p.PodcastID, match_priority, subscribers DESC
+ LIMIT $2
+ `, searchTerms, MAX_DIRECTORY_ITEMS)
+ } else {
+ // MySQL query without DISTINCT ON and window functions
+ rows, err = database.Query(`
+ SELECT
+ p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers,
+ CASE
+ WHEN p.PodcastName LIKE ? THEN 1
+ WHEN p.Author LIKE ? THEN 2
+ WHEN p.Description LIKE ? THEN 3
+ ELSE 4
+ END as match_priority
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ WHERE
+ p.PodcastName LIKE ? OR
+ p.Author LIKE ? OR
+ p.Description LIKE ?
+ GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL, match_priority
+ ORDER BY match_priority, subscribers DESC
+ LIMIT ?
+ `, searchTerms, searchTerms, searchTerms, searchTerms, searchTerms, searchTerms, MAX_DIRECTORY_ITEMS)
+ }
+
+ if err != nil {
+ log.Printf("Error searching podcasts: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search podcasts"})
+ return
+ }
+ defer rows.Close()
+
+ // Build podcast list
+ podcasts := make([]models.Podcast, 0)
+ for rows.Next() {
+ var podcast models.Podcast
+ var podcastID int
+ var author, description, websiteURL, artworkURL sql.NullString
+ var subscribers, matchPriority int
+
+ if err := rows.Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ &matchPriority,
+ ); err != nil {
+ log.Printf("Error scanning podcast: %v", err)
+ continue
+ }
+
+ // Set optional fields if present
+ if author.Valid {
+ podcast.Author = author.String
+ }
+
+ if description.Valid {
+ podcast.Description = description.String
+ }
+
+ if websiteURL.Valid {
+ podcast.Website = websiteURL.String
+ }
+
+ if artworkURL.Valid {
+ podcast.LogoURL = artworkURL.String
+
+ // Add scaled logo URL if requested
+ if scaleLogo != "" {
+ podcast.ScaledLogoURL = fmt.Sprintf("/logo/%d/%s", scaleSize, artworkURL.String)
+ }
+ }
+
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID)
+
+ podcasts = append(podcasts, podcast)
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating podcast rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process search results"})
+ return
+ }
+
+ // Return in requested format
+ switch format {
+ case "json":
+ c.JSON(http.StatusOK, podcasts)
+ case "jsonp":
+ // JSONP callback
+ callback := c.Query("jsonp")
+ if callback == "" {
+ callback = "callback" // Default callback name
+ }
+
+ // Validate callback name for security
+ if !isValidCallbackName(callback) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSONP callback name"})
+ return
+ }
+
+ // Convert to JSON using the standard json package
+ jsonData, err := json.Marshal(podcasts)
+ if err != nil {
+ log.Printf("Error marshaling to JSON: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal to JSON"})
+ return
+ }
+
+ // Wrap in callback
+ c.Header("Content-Type", "application/javascript")
+ c.String(http.StatusOK, "%s(%s);", callback, string(jsonData))
+ 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())
+ case "opml":
+ // OPML format
+ opml := generateOpml(podcasts)
+ c.Header("Content-Type", "text/xml")
+ c.String(http.StatusOK, opml)
+ case "xml":
+ // XML format
+ xml := generateXml(podcasts)
+ c.Header("Content-Type", "text/xml")
+ c.String(http.StatusOK, xml)
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
+ }
+ }
+}
+
+// getToplist handles GET /toplist/{number}.{format}
+func getToplist(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Parse count parameter
+ countStr := c.Param("number")
+ count, err := strconv.Atoi(countStr)
+ if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid number parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)})
+ return
+ }
+
+ // Get format parameter
+ format := c.Param("format")
+ if format == "" {
+ format = "json" // Default format
+ }
+
+ // Parse optional parameters
+ scaleLogo := c.Query("scale_logo")
+ var scaleSize int
+ if scaleLogo != "" {
+ size, err := strconv.Atoi(scaleLogo)
+ if err != nil || size < 1 || size > 256 {
+ scaleSize = 64 // Default size
+ } else {
+ scaleSize = size
+ }
+ }
+
+ // Query top podcasts
+ var rows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ // PostgreSQL query with CTE
+ rows, err = database.Query(`
+ WITH podcast_stats AS (
+ SELECT
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers,
+ 0 as position_last_week -- Placeholder for now
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ GROUP BY p.PodcastID
+ )
+ SELECT * FROM podcast_stats
+ ORDER BY subscribers DESC, PodcastID
+ LIMIT $1
+ `, count)
+ } else {
+ // MySQL query without CTE
+ rows, err = database.Query(`
+ SELECT
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers,
+ 0 as position_last_week -- Placeholder for now
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL
+ ORDER BY subscribers DESC, PodcastID
+ LIMIT ?
+ `, count)
+ }
+
+ if err != nil {
+ log.Printf("Error querying top podcasts: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get top podcasts"})
+ return
+ }
+ defer rows.Close()
+
+ // Build podcast list
+ podcasts := make([]models.Podcast, 0)
+ for rows.Next() {
+ var podcast models.Podcast
+ var podcastID int
+ var author, description, websiteURL, artworkURL sql.NullString
+ var subscribers, positionLastWeek int
+
+ if err := rows.Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ &positionLastWeek,
+ ); err != nil {
+ log.Printf("Error scanning podcast: %v", err)
+ continue
+ }
+
+ // Set optional fields if present
+ if author.Valid {
+ podcast.Author = author.String
+ }
+
+ if description.Valid {
+ podcast.Description = description.String
+ }
+
+ if websiteURL.Valid {
+ podcast.Website = websiteURL.String
+ }
+
+ if artworkURL.Valid {
+ podcast.LogoURL = artworkURL.String
+
+ // Add scaled logo URL if requested
+ if scaleLogo != "" {
+ podcast.ScaledLogoURL = fmt.Sprintf("/logo/%d/%s", scaleSize, artworkURL.String)
+ }
+ }
+
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID)
+
+ podcasts = append(podcasts, podcast)
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating podcast rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process podcasts"})
+ return
+ }
+
+ // Return in requested format (same as search)
+ switch format {
+ case "json":
+ c.JSON(http.StatusOK, podcasts)
+ case "jsonp":
+ // JSONP callback
+ callback := c.Query("jsonp")
+ if callback == "" {
+ callback = "callback" // Default callback name
+ }
+
+ // Validate callback name for security
+ if !isValidCallbackName(callback) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSONP callback name"})
+ return
+ }
+
+ // Convert to JSON using the standard json package
+ jsonData, err := json.Marshal(podcasts)
+ if err != nil {
+ log.Printf("Error marshaling to JSON: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal to JSON"})
+ return
+ }
+
+ // Wrap in callback
+ c.Header("Content-Type", "application/javascript")
+ c.String(http.StatusOK, "%s(%s);", callback, string(jsonData))
+ 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())
+ case "opml":
+ // OPML format
+ opml := generateOpml(podcasts)
+ c.Header("Content-Type", "text/xml")
+ c.String(http.StatusOK, opml)
+ case "xml":
+ // XML format
+ xml := generateXml(podcasts)
+ c.Header("Content-Type", "text/xml")
+ c.String(http.StatusOK, xml)
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
+ }
+ }
+}
+
+// getSuggestions handles GET /suggestions/{count}.{format}
+func getSuggestions(database *db.Database) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Get user ID from middleware
+ userID, exists := c.Get("userID")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ // Parse count parameter
+ countStr := c.Param("count")
+ count, err := strconv.Atoi(countStr)
+ if err != nil || count < 1 || count > MAX_DIRECTORY_ITEMS {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid count parameter: must be between 1 and %d", MAX_DIRECTORY_ITEMS)})
+ return
+ }
+
+ // Get format parameter
+ format := c.Param("format")
+ if format == "" {
+ format = "json" // Default format
+ }
+
+ // Get user's current subscriptions
+ var rows *sql.Rows
+
+ if database.IsPostgreSQLDB() {
+ rows, err = database.Query(`
+ SELECT FeedURL FROM "Podcasts" WHERE UserID = $1
+ `, userID)
+ } else {
+ rows, err = database.Query(`
+ SELECT FeedURL FROM Podcasts WHERE UserID = ?
+ `, userID)
+ }
+
+ if err != nil {
+ log.Printf("Error getting user subscriptions: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"})
+ return
+ }
+ defer rows.Close()
+
+ // Build map of current subscriptions
+ currentSubs := make(map[string]bool)
+ for rows.Next() {
+ var url string
+ if err := rows.Scan(&url); err != nil {
+ log.Printf("Error scanning subscription URL: %v", err)
+ continue
+ }
+ currentSubs[url] = true
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating subscription rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"})
+ return
+ }
+
+ // Query for similar podcasts based on categories of current subscriptions
+ if database.IsPostgreSQLDB() {
+ rows, err = database.Query(`
+ WITH user_categories AS (
+ SELECT DISTINCT unnest(string_to_array(p.Categories, ',')) as category
+ FROM "Podcasts" p
+ WHERE p.UserID = $1 AND p.Categories IS NOT NULL AND p.Categories != ''
+ ),
+ recommended_podcasts AS (
+ SELECT DISTINCT ON (p.PodcastID)
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ WHERE EXISTS (
+ SELECT 1 FROM user_categories uc
+ WHERE p.Categories ILIKE '%' || uc.category || '%'
+ )
+ AND p.FeedURL NOT IN (
+ SELECT FeedURL FROM "Podcasts" WHERE UserID = $1
+ )
+ GROUP BY p.PodcastID
+ ORDER BY p.PodcastID, subscribers DESC
+ )
+ SELECT * FROM recommended_podcasts
+ LIMIT $2
+ `, userID, count)
+ } else {
+ // For MySQL, we use a simpler approach without CTEs and array functions
+ rows, err = database.Query(`
+ SELECT DISTINCT
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ JOIN (
+ SELECT DISTINCT p.Categories
+ FROM Podcasts p
+ WHERE p.UserID = ? AND p.Categories IS NOT NULL AND p.Categories != ''
+ ) as user_cats
+ WHERE p.Categories LIKE CONCAT('%', user_cats.Categories, '%')
+ AND p.FeedURL NOT IN (
+ SELECT FeedURL FROM Podcasts WHERE UserID = ?
+ )
+ GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL
+ ORDER BY subscribers DESC, p.PodcastID
+ LIMIT ?
+ `, userID, userID, count)
+ }
+
+ if err != nil {
+ log.Printf("Error querying suggested podcasts: %v", err)
+
+ // If category-based query fails, fall back to popularity-based suggestions
+ if database.IsPostgreSQLDB() {
+ rows, err = database.Query(`
+ SELECT
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM "Podcasts" p
+ JOIN "Users" u ON p.UserID = u.UserID
+ WHERE p.FeedURL NOT IN (
+ SELECT FeedURL FROM "Podcasts" WHERE UserID = $1
+ )
+ GROUP BY p.PodcastID
+ ORDER BY subscribers DESC, p.PodcastID
+ LIMIT $2
+ `, userID, count)
+ } else {
+ rows, err = database.Query(`
+ SELECT
+ p.PodcastID,
+ p.PodcastName,
+ p.Author,
+ p.Description,
+ p.FeedURL,
+ p.WebsiteURL,
+ p.ArtworkURL,
+ COUNT(DISTINCT u.UserID) as subscribers
+ FROM Podcasts p
+ JOIN Users u ON p.UserID = u.UserID
+ WHERE p.FeedURL NOT IN (
+ SELECT FeedURL FROM Podcasts WHERE UserID = ?
+ )
+ GROUP BY p.PodcastID, p.PodcastName, p.Author, p.Description,
+ p.FeedURL, p.WebsiteURL, p.ArtworkURL
+ ORDER BY subscribers DESC, p.PodcastID
+ LIMIT ?
+ `, userID, count)
+ }
+
+ if err != nil {
+ log.Printf("Error querying popular podcasts: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"})
+ return
+ }
+ }
+ defer rows.Close()
+
+ // Build podcast list
+ podcasts := make([]models.Podcast, 0)
+ for rows.Next() {
+ var podcast models.Podcast
+ var podcastID int
+ var author, description, websiteURL, artworkURL sql.NullString
+ var subscribers int
+
+ if err := rows.Scan(
+ &podcastID,
+ &podcast.Title,
+ &author,
+ &description,
+ &podcast.URL,
+ &websiteURL,
+ &artworkURL,
+ &subscribers,
+ ); err != nil {
+ log.Printf("Error scanning podcast: %v", err)
+ continue
+ }
+
+ // Skip if already subscribed (double-check)
+ if currentSubs[podcast.URL] {
+ continue
+ }
+
+ // Set optional fields if present
+ if author.Valid {
+ podcast.Author = author.String
+ }
+
+ if description.Valid {
+ podcast.Description = description.String
+ }
+
+ if websiteURL.Valid {
+ podcast.Website = websiteURL.String
+ }
+
+ if artworkURL.Valid {
+ podcast.LogoURL = artworkURL.String
+ }
+
+ podcast.Subscribers = subscribers
+ podcast.MygpoLink = fmt.Sprintf("/podcast/%d", podcastID)
+
+ podcasts = append(podcasts, podcast)
+ }
+
+ if err = rows.Err(); err != nil {
+ log.Printf("Error iterating suggestion rows: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process suggestions"})
+ return
+ }
+
+ // 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())
+ case "opml":
+ // OPML format
+ opml := generateOpml(podcasts)
+ c.Header("Content-Type", "text/xml")
+ c.String(http.StatusOK, opml)
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"})
+ }
+ }
+}
+
+// generateOpml generates an OPML format document from a list of podcasts
+func generateOpml(podcasts []models.Podcast) string {
+ var sb strings.Builder
+
+ sb.WriteString(`
+