-
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
deleted file mode 100644
index 498dea2..0000000
--- a/PinePods-0.8.2/docs/index.yaml
+++ /dev/null
@@ -1,264 +0,0 @@
-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
deleted file mode 100644
index e7c0807..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.6.2.tgz and /dev/null 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
deleted file mode 100644
index 3e27666..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.6.3.tgz and /dev/null 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
deleted file mode 100644
index 62cd8a1..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.6.4.tgz and /dev/null 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
deleted file mode 100644
index 2327d12..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.6.5.tgz and /dev/null 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
deleted file mode 100644
index 745bddd..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.6.6.tgz and /dev/null 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
deleted file mode 100644
index 6ab2006..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.0.tgz and /dev/null 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
deleted file mode 100644
index ae216d1..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.1.tgz and /dev/null 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
deleted file mode 100644
index b509fd1..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.2.tgz and /dev/null 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
deleted file mode 100644
index 14eae02..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.3.tgz and /dev/null 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
deleted file mode 100644
index 56d6b2a..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.4.tgz and /dev/null 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
deleted file mode 100644
index 830c166..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.5.tgz and /dev/null 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
deleted file mode 100644
index 4649360..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.6.tgz and /dev/null 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
deleted file mode 100644
index c6e4b25..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.7.tgz and /dev/null 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
deleted file mode 100644
index 7b40bcd..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.7.8.tgz and /dev/null 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
deleted file mode 100644
index 81b0d6b..0000000
Binary files a/PinePods-0.8.2/docs/pinepods-0.8.1.tgz and /dev/null differ
diff --git a/PinePods-0.8.2/docs/pinepods.png b/PinePods-0.8.2/docs/pinepods.png
deleted file mode 100644
index e218d09..0000000
Binary files a/PinePods-0.8.2/docs/pinepods.png and /dev/null 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
deleted file mode 100644
index 80dc00a..0000000
--- a/PinePods-0.8.2/fastlane/metadata/android/en-US/full_description.txt
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index e7aab93..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/featureGraphic.png and /dev/null 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
deleted file mode 100644
index 4fe781c..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/icon.png and /dev/null 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
deleted file mode 100644
index a5c21ee..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null 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
deleted file mode 100644
index 668c407..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null 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
deleted file mode 100644
index 6182ba2..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null 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
deleted file mode 100644
index 1d45d00..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null 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
deleted file mode 100644
index 4c00442..0000000
Binary files a/PinePods-0.8.2/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null 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
deleted file mode 100644
index 503b6d1..0000000
--- a/PinePods-0.8.2/fastlane/metadata/android/en-US/short_description.txt
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 50437f6..0000000
--- a/PinePods-0.8.2/fastlane/metadata/android/en-US/title.txt
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 62fb1fb..0000000
--- a/PinePods-0.8.2/gpodder-api/cmd/server/main.go
+++ /dev/null
@@ -1,110 +0,0 @@
-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
deleted file mode 100644
index bf159e8..0000000
--- a/PinePods-0.8.2/gpodder-api/config/config.go
+++ /dev/null
@@ -1,93 +0,0 @@
-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
deleted file mode 100644
index d931701..0000000
--- a/PinePods-0.8.2/gpodder-api/go.mod
+++ /dev/null
@@ -1,46 +0,0 @@
-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
deleted file mode 100644
index 0a39cce..0000000
--- a/PinePods-0.8.2/gpodder-api/go.sum
+++ /dev/null
@@ -1,170 +0,0 @@
-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
deleted file mode 100644
index 93bffa1..0000000
--- a/PinePods-0.8.2/gpodder-api/internal/api/auth.go
+++ /dev/null
@@ -1,850 +0,0 @@
-// 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
deleted file mode 100644
index 8850248..0000000
--- a/PinePods-0.8.2/gpodder-api/internal/api/device.go
+++ /dev/null
@@ -1,1039 +0,0 @@
-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
deleted file mode 100644
index 941e307..0000000
--- a/PinePods-0.8.2/gpodder-api/internal/api/directory.go
+++ /dev/null
@@ -1,1117 +0,0 @@
-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(`
-