This commit is contained in:
Tomas Dvorak
2026-02-24 10:33:08 +01:00
parent b083dac3f0
commit 55d0284b2a
90 changed files with 27855 additions and 1940 deletions
+118
View File
@@ -0,0 +1,118 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type ServerConfig struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
}
type Config struct {
Server ServerConfig
Database DatabaseConfig
App AppConfig
}
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
type AppConfig struct {
Version string
DemoMode bool
GinMode string
JWTSecret string
CorsOrigins string
}
func Load() *Config {
return &Config{
Server: ServerConfig{
Port: getEnvWithDefault("PORT", "8080"),
ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second),
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: getDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second),
},
Database: DatabaseConfig{
Host: getEnvWithDefault("DB_HOST", "localhost"),
Port: getEnvWithDefault("DB_PORT", "5432"),
User: getEnvWithDefault("DB_USER", "trackeep"),
Password: os.Getenv("DB_PASSWORD"),
Name: getEnvWithDefault("DB_NAME", "trackeep"),
SSLMode: getEnvWithDefault("DB_SSL_MODE", "disable"),
},
App: AppConfig{
Version: getEnvWithDefault("APP_VERSION", "1.0.0"),
DemoMode: os.Getenv("VITE_DEMO_MODE") == "true",
GinMode: getEnvWithDefault("GIN_MODE", "debug"),
JWTSecret: os.Getenv("JWT_SECRET"),
CorsOrigins: getEnvWithDefault("CORS_ALLOWED_ORIGINS", ""),
},
}
}
func (c *Config) Validate() error {
if c.Database.Password == "" && !c.App.DemoMode {
return fmt.Errorf("DB_PASSWORD environment variable is required")
}
if c.App.GinMode == "release" && c.App.CorsOrigins == "" {
return fmt.Errorf("CORS_ALLOWED_ORIGINS must be set in production mode")
}
if c.App.GinMode == "release" && c.App.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET must be set in production mode")
}
return nil
}
func (c *Config) DSN() string {
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
c.Database.Host,
c.Database.User,
c.Database.Password,
c.Database.Name,
c.Database.Port,
c.Database.SSLMode,
)
}
func getEnvWithDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
seconds, err := strconv.Atoi(value)
if err != nil {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return time.Duration(seconds) * time.Second
}
+32
View File
@@ -0,0 +1,32 @@
package handlers
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
func GetAPIConfig(c *gin.Context) {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
if host == "" {
host = os.Getenv("HOST")
if host == "" {
host = "localhost:8080"
}
}
apiURL := fmt.Sprintf("%s://%s/api/v1", scheme, host)
c.JSON(http.StatusOK, gin.H{
"api_url": apiURL,
"demo_mode": os.Getenv("VITE_DEMO_MODE") == "true",
"version": "1.0.0",
})
}
+103
View File
@@ -0,0 +1,103 @@
package handlers
import (
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/config"
)
var startTime = time.Now()
func HealthCheck(c *gin.Context) {
db := config.GetDB()
dbStatus := "connected"
var dbPingTime time.Duration = 0
if db == nil {
dbStatus = "disconnected"
} else {
sqlDB, err := db.DB()
if err != nil {
dbStatus = "error"
} else {
start := time.Now()
if err := sqlDB.Ping(); err != nil {
dbStatus = "error"
} else {
dbPingTime = time.Since(start)
}
}
}
sessionStatus := "ok"
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
uptime := time.Since(startTime)
health := gin.H{
"status": "ok",
"message": "Trackeep API is running",
"version": "1.0.0",
"uptime": uptime.String(),
"database": gin.H{
"status": dbStatus,
"ping_time": dbPingTime.String(),
},
"sessions": gin.H{
"status": sessionStatus,
},
"system": gin.H{
"goroutines": runtime.NumGoroutine(),
"memory": gin.H{
"alloc_mb": memStats.Alloc / 1024 / 1024,
"total_alloc_mb": memStats.TotalAlloc / 1024 / 1024,
"sys_mb": memStats.Sys / 1024 / 1024,
},
},
"timestamp": gin.H{
"human": time.Now().Format(time.RFC3339),
"unix": time.Now().Unix(),
},
}
overallStatus := "healthy"
if dbStatus != "connected" {
overallStatus = "degraded"
health["status"] = "degraded"
}
if sessionStatus != "ok" {
overallStatus = "degraded"
health["status"] = "degraded"
}
statusCode := 200
if overallStatus == "degraded" {
statusCode = 503
}
c.JSON(statusCode, health)
}
func ReadinessCheck(c *gin.Context) {
db := config.GetDB()
if db == nil {
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_not_connected"})
return
}
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_ping_failed"})
return
}
c.JSON(200, gin.H{"status": "ready"})
}
func LivenessCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "alive", "timestamp": time.Now().Unix()})
}
+24 -7
View File
@@ -1,8 +1,10 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
@@ -25,10 +27,8 @@ type BraveSearchResponse struct {
}
type BraveNewsResponse struct {
News struct {
Results []map[string]interface{} `json:"results"`
} `json:"news"`
Query struct {
Results []map[string]interface{} `json:"results"`
Query struct {
Original string `json:"original"`
Display string `json:"display"`
} `json:"query"`
@@ -47,6 +47,7 @@ type BraveSearchResult struct {
// SearchWeb handles POST /api/v1/search/web
func SearchWeb(c *gin.Context) {
fmt.Printf("DEBUG: SearchWeb function called\n")
var req struct {
Query string `json:"query" binding:"required"`
Count int `json:"count"`
@@ -135,6 +136,7 @@ func SearchWeb(c *gin.Context) {
}
func SearchNews(c *gin.Context) {
fmt.Printf("DEBUG: SearchNews function called\n")
var req struct {
Query string `json:"query" binding:"required"`
Count int `json:"count"`
@@ -174,20 +176,35 @@ func SearchNews(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
return
}
// Read the response body for debugging
bodyBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
return
}
fmt.Printf("DEBUG: Raw Brave News API response: %s\n", string(bodyBytes))
var braveResp BraveNewsResponse
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
fmt.Printf("DEBUG: JSON decode error: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
return
}
resultsRaw := braveResp.News.Results
// Debug logging
fmt.Printf("DEBUG: Parsed BraveNewsResponse: %+v\n", braveResp)
fmt.Printf("DEBUG: Number of results: %d\n", len(braveResp.Results))
resultsRaw := braveResp.Results
results := make([]BraveSearchResult, 0, len(resultsRaw))
for _, r := range resultsRaw {
title, _ := r["title"].(string)
+596 -46
View File
@@ -1,6 +1,10 @@
package handlers
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
@@ -9,10 +13,13 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// UpdateInfo represents information about an available update
@@ -22,6 +29,9 @@ type UpdateInfo struct {
DownloadURL string `json:"downloadUrl"`
Mandatory bool `json:"mandatory"`
Size string `json:"size"`
Checksum string `json:"checksum"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
}
// UpdateStatus represents the current status of an update
@@ -44,6 +54,7 @@ var (
updateMutex sync.RWMutex
currentUpdate *UpdateInfo
updateProgress *UpdateStatus
backupPath string // Store backup path for rollback
)
func init() {
@@ -68,18 +79,32 @@ func CheckForUpdates(c *gin.Context) {
currentVersion = "1.0.0"
}
// In a real implementation, this would check against a remote update server
// For demo purposes, we'll simulate checking for updates
latestVersion, updateAvailable := simulateUpdateCheck(currentVersion)
// Get GitHub token from OAuth service (required)
githubToken := getGitHubTokenFromContext(c)
if githubToken == "" {
log.Printf("No GitHub token from OAuth service - update check failed")
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "OAuth service not available",
"message": "Please ensure OAuth service is running and you are authenticated",
})
return
}
log.Printf("Using GitHub token from OAuth service for update check")
// Check for updates using GitHub API
updateInfo, updateAvailable, err := checkForUpdatesWithGitHub(currentVersion, githubToken)
if err != nil {
log.Printf("Failed to check for updates: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to check for updates",
"details": err.Error(),
})
return
}
if updateAvailable {
currentUpdate = &UpdateInfo{
Version: latestVersion,
ReleaseNotes: "• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface",
DownloadURL: "https://github.com/trackeep/trackeep/releases/latest",
Mandatory: false,
Size: "~25MB",
}
currentUpdate = updateInfo
updateProgress.Available = true
} else {
currentUpdate = nil
@@ -89,7 +114,7 @@ func CheckForUpdates(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"updateAvailable": updateAvailable,
"currentVersion": currentVersion,
"latestVersion": latestVersion,
"latestVersion": updateInfo.Version,
"updateInfo": currentUpdate,
})
}
@@ -140,17 +165,275 @@ func UpdateProgressWebSocket(c *gin.Context) {
})
}
// simulateUpdateCheck simulates checking for updates
func simulateUpdateCheck(currentVersion string) (string, bool) {
// Simulate version check - in reality this would call an update API
versions := []string{"1.0.1", "1.1.0", "1.2.0"}
// checkForUpdatesWithGitHub checks for updates using GitHub API
func checkForUpdatesWithGitHub(currentVersion, githubToken string) (*UpdateInfo, bool, error) {
// GitHub repository information
owner := "Dvorinka"
repo := "Trackeep"
// For demo, always return a newer version
if len(versions) > 0 {
return versions[0], true
// Log which token source we're using
if githubToken != "" {
log.Printf("Using GitHub token from OAuth service")
} else {
log.Printf("No GitHub token available - OAuth service should be running")
return nil, false, fmt.Errorf("OAuth service not available - please ensure OAuth service is running")
}
return currentVersion, false
// Create HTTP request to GitHub API
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, false, fmt.Errorf("failed to create request: %w", err)
}
// Add authorization header if token is available
if githubToken != "" {
req.Header.Set("Authorization", "token "+githubToken)
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
// Make the request
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, false, fmt.Errorf("failed to fetch releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
// Parse the release response
var release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Assets []struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, false, fmt.Errorf("failed to parse release response: %w", err)
}
// Compare versions (simple semantic version comparison)
if !isNewerVersion(release.TagName, currentVersion) {
return nil, false, nil
}
// Find the appropriate asset for the current platform
var downloadURL, size, checksum string
for _, asset := range release.Assets {
// Look for platform-specific binaries
if isPlatformAsset(asset.Name) {
downloadURL = asset.BrowserDownloadURL
size = formatBytes(asset.Size)
break
}
}
// If no platform-specific asset found, use the first one
if downloadURL == "" && len(release.Assets) > 0 {
downloadURL = release.Assets[0].BrowserDownloadURL
size = formatBytes(release.Assets[0].Size)
}
// Try to get checksum from release notes or assets
checksum = extractChecksum(release.Body)
updateInfo := &UpdateInfo{
Version: release.TagName,
ReleaseNotes: release.Body,
DownloadURL: downloadURL,
Mandatory: false, // Could be determined from release notes or tags
Size: size,
Checksum: checksum,
PublishedAt: release.PublishedAt,
Prerelease: release.Prerelease,
}
return updateInfo, true, nil
}
// getGitHubTokenFromContext extracts GitHub token from request context
func getGitHubTokenFromContext(c *gin.Context) string {
// Extract Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return ""
}
// Remove "Bearer " prefix
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
// No Bearer prefix found
return ""
}
// Parse JWT token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("JWT_SECRET")), nil
})
if err != nil || !token.Valid {
return ""
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return ""
}
// Get GitHub access token from claims
githubToken, ok := claims["access_token"]
if !ok {
return ""
}
// Check if token is still valid
expiresAt, ok := claims["expires_at"]
if ok {
if expTime, ok := expiresAt.(float64); ok {
if time.Now().Unix() > int64(expTime) {
return "" // Token expired
}
}
}
return githubToken.(string)
}
// Helper functions for GitHub update functionality
// getGitHubTokenFromOAuth attempts to get GitHub token from OAuth service
func getGitHubTokenFromOAuth() string {
// Try to get token from current user session
// This would typically be extracted from the JWT token in the request context
// For now, we'll implement a basic version that checks for a logged-in user
// In a real implementation, this would:
// 1. Extract the JWT from the current request context
// 2. Parse the JWT to get the GitHub access token
// 3. Return the token if valid
// For now, return empty string to indicate no OAuth token available
// This will be implemented when we have proper session management
return ""
}
// isNewerVersion compares semantic versions
func isNewerVersion(latest, current string) bool {
// Remove 'v' prefix if present
latest = strings.TrimPrefix(latest, "v")
current = strings.TrimPrefix(current, "v")
latestParts := strings.Split(latest, ".")
currentParts := strings.Split(current, ".")
for i := 0; i < 3; i++ {
var latestNum, currentNum int
var err error
if i < len(latestParts) {
latestNum, err = strconv.Atoi(latestParts[i])
if err != nil {
latestNum = 0
}
}
if i < len(currentParts) {
currentNum, err = strconv.Atoi(currentParts[i])
if err != nil {
currentNum = 0
}
}
if latestNum > currentNum {
return true
}
if latestNum < currentNum {
return false
}
}
return false
}
// isPlatformAsset checks if an asset is appropriate for the current platform
func isPlatformAsset(filename string) bool {
arch := runtime.GOARCH
os := runtime.GOOS
filename = strings.ToLower(filename)
// Check for platform-specific patterns
switch os {
case "windows":
return strings.Contains(filename, "windows") || strings.Contains(filename, "win") || strings.HasSuffix(filename, ".exe")
case "linux":
return strings.Contains(filename, "linux") || strings.Contains(filename, "ubuntu") || strings.Contains(filename, "debian")
case "darwin":
return strings.Contains(filename, "darwin") || strings.Contains(filename, "macos") || strings.Contains(filename, "mac")
}
// Check architecture
if arch == "amd64" {
return strings.Contains(filename, "amd64") || strings.Contains(filename, "x86_64")
}
if arch == "arm64" {
return strings.Contains(filename, "arm64") || strings.Contains(filename, "aarch64")
}
return false
}
// formatBytes formats bytes into human readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// extractChecksum attempts to extract SHA256 checksum from release notes
func extractChecksum(body string) string {
lines := strings.Split(body, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "SHA256:") || strings.HasPrefix(line, "Checksum:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
return parts[1]
}
}
// Also look for pattern like "checksum: sha256:..."
if strings.Contains(line, "sha256:") {
idx := strings.Index(line, "sha256:")
if idx != -1 {
checksum := strings.TrimSpace(line[idx+7:])
if len(checksum) == 64 { // SHA256 length
return checksum
}
}
}
}
return ""
}
// performUpdate performs the actual update process
@@ -161,18 +444,31 @@ func performUpdate(updateInfo *UpdateInfo) {
updateProgress.Error = ""
updateMutex.Unlock()
// Broadcast progress update
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
// Simulate download
for i := 0; i <= 100; i += 10 {
time.Sleep(500 * time.Millisecond)
log.Printf("Starting update to version %s", updateInfo.Version)
// Download the update
tempFile, err := downloadUpdate(updateInfo)
if err != nil {
updateMutex.Lock()
updateProgress.Progress = float64(i)
updateProgress.Downloading = false
updateProgress.Error = fmt.Sprintf("Failed to download update: %v", err)
updateMutex.Unlock()
log.Printf("Update download failed: %v", err)
return
}
defer os.Remove(tempFile)
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
// Verify checksum if available
if updateInfo.Checksum != "" {
if err := verifyChecksum(tempFile, updateInfo.Checksum); err != nil {
updateMutex.Lock()
updateProgress.Downloading = false
updateProgress.Error = fmt.Sprintf("Checksum verification failed: %v", err)
updateMutex.Unlock()
log.Printf("Checksum verification failed: %v", err)
return
}
log.Printf("Checksum verification passed")
}
// Start installation
@@ -182,36 +478,30 @@ func performUpdate(updateInfo *UpdateInfo) {
updateProgress.Progress = 0
updateMutex.Unlock()
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
// Backup user data
if err := backupUserData(); err != nil {
updateMutex.Lock()
updateProgress.Installing = false
updateProgress.Error = fmt.Sprintf("Failed to backup user data: %v", err)
updateMutex.Unlock()
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
log.Printf("Backup failed: %v", err)
return
}
// Simulate installation
for i := 0; i <= 100; i += 20 {
time.Sleep(1 * time.Second)
// Extract and install the update
if err := extractAndInstall(tempFile, updateInfo); err != nil {
// Attempt rollback on failure
log.Printf("Installation failed, attempting rollback: %v", err)
if rollbackErr := rollbackUpdate(); rollbackErr != nil {
log.Printf("Rollback also failed: %v", rollbackErr)
} else {
log.Printf("Rollback completed successfully")
}
updateMutex.Lock()
updateProgress.Progress = float64(i)
updateMutex.Unlock()
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
}
// Perform the actual update
if err := applyUpdate(updateInfo); err != nil {
updateMutex.Lock()
updateProgress.Installing = false
updateProgress.Error = fmt.Sprintf("Failed to apply update: %v", err)
updateProgress.Error = fmt.Sprintf("Failed to install update: %v", err)
updateMutex.Unlock()
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
return
}
@@ -222,22 +512,215 @@ func performUpdate(updateInfo *UpdateInfo) {
updateProgress.Progress = 100
updateMutex.Unlock()
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
log.Printf("Update to version %s completed successfully", updateInfo.Version)
// Trigger application restart after a delay
time.Sleep(2 * time.Second)
restartApplication()
}
// downloadUpdate downloads the update file with progress tracking
func downloadUpdate(updateInfo *UpdateInfo) (string, error) {
if updateInfo.DownloadURL == "" {
return "", fmt.Errorf("no download URL available")
}
// Create temporary file
tempFile, err := os.CreateTemp("", "trackeep-update-*.zip")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tempFile.Close()
// Make HTTP request
req, err := http.NewRequest("GET", updateInfo.DownloadURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Get content length for progress tracking
contentLength := resp.ContentLength
var downloaded int64
// Create progress reporter
progress := make(chan int64)
go func() {
for {
bytes, ok := <-progress
if !ok {
return
}
downloaded += bytes
if contentLength > 0 {
percent := float64(downloaded) / float64(contentLength) * 100
updateMutex.Lock()
updateProgress.Progress = percent
updateMutex.Unlock()
log.Printf("Download progress: %.1f%%", percent)
}
}
}()
// Download with progress tracking
writer := &progressWriter{writer: tempFile, progress: progress}
_, err = io.Copy(writer, resp.Body)
close(progress)
if err != nil {
return "", fmt.Errorf("failed to save download: %w", err)
}
return tempFile.Name(), nil
}
// progressWriter tracks download progress
type progressWriter struct {
writer io.Writer
progress chan<- int64
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err == nil && n > 0 {
pw.progress <- int64(n)
}
return n, err
}
// verifyChecksum verifies the SHA256 checksum of a file
func verifyChecksum(filePath, expectedChecksum string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to calculate checksum: %w", err)
}
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(actualChecksum, expectedChecksum) {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
}
return nil
}
// extractAndInstall extracts the update and installs it
func extractAndInstall(filePath string, updateInfo *UpdateInfo) error {
// Get current executable path
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
// Get directory of executable
installDir := filepath.Dir(executable)
// Open the zip file
reader, err := zip.OpenReader(filePath)
if err != nil {
return fmt.Errorf("failed to open zip file: %w", err)
}
defer reader.Close()
// Extract files
totalFiles := len(reader.File)
extractedFiles := 0
for _, file := range reader.File {
// Update progress
progress := float64(extractedFiles) / float64(totalFiles) * 100
updateMutex.Lock()
updateProgress.Progress = progress
updateMutex.Unlock()
// Skip directories for now
if file.FileInfo().IsDir() {
continue
}
// Create file path
filePath := filepath.Join(installDir, file.Name)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Extract file
if err := extractFile(file, filePath); err != nil {
return fmt.Errorf("failed to extract %s: %w", file.Name, err)
}
extractedFiles++
log.Printf("Extracted: %s", file.Name)
}
// Update version in environment
os.Setenv("APP_VERSION", updateInfo.Version)
return nil
}
// extractFile extracts a single file from zip
func extractFile(file *zip.File, destination string) error {
// Open file in zip
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
// Create destination file
destFile, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
if err != nil {
return err
}
defer destFile.Close()
// Copy file contents
_, err = io.Copy(destFile, rc)
return err
}
// backupUserData creates a backup of user data
func backupUserData() error {
backupDir := filepath.Join(os.TempDir(), "trackeep_backup", time.Now().Format("20060102_150405"))
// Store backup path globally for potential rollback
backupPath = backupDir
// Create backup directory
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %w", err)
}
// Get current executable path
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
// Backup current executable
backupExecPath := filepath.Join(backupDir, filepath.Base(executable))
if err := copyFile(executable, backupExecPath); err != nil {
return fmt.Errorf("failed to backup executable: %w", err)
}
// Backup database
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
@@ -354,7 +837,74 @@ func getUpdateStatusString(status UpdateStatus) string {
return "Not Available"
}
// copyFile copies a file from src to dst
// rollbackUpdate restores the application from backup
func rollbackUpdate() error {
if backupPath == "" {
return fmt.Errorf("no backup path available for rollback")
}
log.Printf("Starting rollback from backup: %s", backupPath)
// Get current executable path
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
// Restore executable
backupExecPath := filepath.Join(backupPath, filepath.Base(executable))
if _, err := os.Stat(backupExecPath); err == nil {
if err := copyFile(backupExecPath, executable); err != nil {
return fmt.Errorf("failed to restore executable: %w", err)
}
log.Printf("Restored executable from backup")
}
// Restore database
backupDBPath := filepath.Join(backupPath, "trackeep.db")
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./trackeep.db"
}
if _, err := os.Stat(backupDBPath); err == nil {
if err := copyFile(backupDBPath, dbPath); err != nil {
return fmt.Errorf("failed to restore database: %w", err)
}
log.Printf("Restored database from backup")
}
// Restore uploads directory
backupUploadsDir := filepath.Join(backupPath, "uploads")
uploadsDir := "./uploads"
if _, err := os.Stat(backupUploadsDir); err == nil {
// Remove current uploads directory and restore from backup
if err := os.RemoveAll(uploadsDir); err != nil {
log.Printf("Warning: failed to remove uploads directory: %v", err)
}
if err := copyDirectory(backupUploadsDir, uploadsDir); err != nil {
return fmt.Errorf("failed to restore uploads: %w", err)
}
log.Printf("Restored uploads from backup")
}
// Restore configuration files
configFiles := []string{".env", "docker-compose.yml"}
for _, file := range configFiles {
backupFile := filepath.Join(backupPath, file)
if _, err := os.Stat(backupFile); err == nil {
if err := copyFile(backupFile, file); err != nil {
log.Printf("Warning: failed to restore %s: %v", file, err)
} else {
log.Printf("Restored %s from backup", file)
}
}
}
log.Printf("Rollback completed successfully")
return nil
}
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
+29 -4
View File
@@ -171,17 +171,13 @@ func GetYouTubeTrending(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// GetPredefinedChannelVideos handles GET /api/v1/youtube/predefined-channels
func GetPredefinedChannelVideos(c *gin.Context) {
// Get query parameters
maxResults, _ := strconv.Atoi(c.DefaultQuery("max_results", "5"))
// Validate max results
if maxResults < 1 || maxResults > 20 {
maxResults = 10
}
// Get videos from predefined channels
response, err := services.GetPredefinedChannelVideos(maxResults)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -193,3 +189,32 @@ func GetPredefinedChannelVideos(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
func YouTubeSearchTest(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
MaxResults int `json:"max_results"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.MaxResults <= 0 {
req.MaxResults = 5
}
response, err := services.SearchYouTubeVideos(req.Query, req.MaxResults, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to search YouTube videos",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
}
+20 -248
View File
@@ -2,15 +2,11 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
@@ -18,29 +14,24 @@ import (
"github.com/trackeep/backend/handlers"
"github.com/trackeep/backend/middleware"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/services"
"github.com/trackeep/backend/utils"
)
// IsDemoMode checks if demo mode is enabled
func IsDemoMode() bool {
return os.Getenv("VITE_DEMO_MODE") == "true"
}
// initializeSecuritySecrets sets up JWT and encryption secrets on startup
func initializeSecuritySecrets() error {
// Initialize JWT secret
jwtSecret, err := utils.GetOrCreateJWTSecret()
if err != nil {
return fmt.Errorf("failed to initialize JWT secret: %w", err)
return err
}
os.Setenv("JWT_SECRET", jwtSecret)
log.Println("JWT secret initialized successfully")
// Initialize encryption key
encryptionKey, err := utils.GetOrCreateEncryptionKey()
if err != nil {
return fmt.Errorf("failed to initialize encryption key: %w", err)
return err
}
os.Setenv("ENCRYPTION_KEY", encryptionKey)
log.Println("Encryption key initialized successfully")
@@ -48,13 +39,9 @@ func initializeSecuritySecrets() error {
return nil
}
var startTime = time.Now()
func main() {
// Set application version
os.Setenv("APP_VERSION", "1.0.0")
// Load environment variables from multiple possible locations
envPaths := []string{".env", "../.env", "/app/.env"}
envLoaded := false
@@ -70,8 +57,12 @@ func main() {
log.Println("No .env file found, using environment variables only")
}
// Initialize database (skip in demo mode)
if !IsDemoMode() {
cfg := config.Load()
if err := cfg.Validate(); err != nil {
log.Printf("Configuration warning: %v", err)
}
if !cfg.App.DemoMode {
config.InitDatabase()
models.InitDB()
models.AutoMigrate()
@@ -115,191 +106,12 @@ func main() {
// Apply general rate limiting to all endpoints
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
// CORS middleware - environment-aware
r.Use(func(c *gin.Context) {
allowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
ginMode := os.Getenv("GIN_MODE")
r.Use(middleware.CORSMiddleware())
// Default origins based on environment
if allowedOrigins == "" {
if ginMode == "release" {
// Production: no default origins - must be explicitly set
c.JSON(http.StatusForbidden, gin.H{
"error": "CORS not configured for production",
"message": "Please set CORS_ALLOWED_ORIGINS environment variable",
})
c.Abort()
return
} else {
// Development: allow localhost origins
allowedOrigins = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
}
}
origin := c.Request.Header.Get("Origin")
allowed := false
// Handle wildcard origin
if allowedOrigins == "*" {
allowed = true
} else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
allowed = true
break
}
}
}
if allowed {
if allowedOrigins == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
}
}
// Set other CORS headers
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400") // 24 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Enhanced health check endpoint
r.GET("/health", func(c *gin.Context) {
// Check database connection
db := config.GetDB()
dbStatus := "connected"
var dbPingTime time.Duration = 0
if db == nil {
dbStatus = "disconnected"
} else {
sqlDB, err := db.DB()
if err != nil {
dbStatus = "error"
} else {
start := time.Now()
if err := sqlDB.Ping(); err != nil {
dbStatus = "error"
} else {
dbPingTime = time.Since(start)
}
}
}
// Check session store
sessionStatus := "ok"
// Since sessionStore is package-private in middleware, we assume it's initialized
// if we've reached this point in the code execution
// Get system stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
uptime := time.Since(startTime)
health := gin.H{
"status": "ok",
"message": "Trackeep API is running",
"version": "1.0.0",
"uptime": uptime.String(),
"database": gin.H{
"status": dbStatus,
"ping_time": dbPingTime.String(),
},
"sessions": gin.H{
"status": sessionStatus,
},
"system": gin.H{
"goroutines": runtime.NumGoroutine(),
"memory": gin.H{
"alloc_mb": memStats.Alloc / 1024 / 1024,
"total_alloc_mb": memStats.TotalAlloc / 1024 / 1024,
"sys_mb": memStats.Sys / 1024 / 1024,
},
},
"timestamp": gin.H{
"human": time.Now().Format(time.RFC3339),
"unix": time.Now().Unix(),
},
}
// Determine overall health status
overallStatus := "healthy"
if dbStatus != "connected" {
overallStatus = "degraded"
health["status"] = "degraded"
}
if sessionStatus != "ok" {
overallStatus = "degraded"
health["status"] = "degraded"
}
statusCode := 200
if overallStatus == "degraded" {
statusCode = 503
}
c.JSON(statusCode, health)
})
// Readiness probe endpoint (for Kubernetes/container orchestration)
r.GET("/ready", func(c *gin.Context) {
db := config.GetDB()
if db == nil {
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_not_connected"})
return
}
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_ping_failed"})
return
}
// Assume session store is initialized if we're running
c.JSON(200, gin.H{"status": "ready"})
})
// API configuration endpoint for frontend
r.GET("/api/v1/config", func(c *gin.Context) {
// Get the current request info to determine base URL
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
if host == "" {
// Fallback to environment or localhost
host = os.Getenv("HOST")
if host == "" {
host = "localhost:8080"
}
}
apiURL := fmt.Sprintf("%s://%s/api/v1", scheme, host)
c.JSON(http.StatusOK, gin.H{
"api_url": apiURL,
"demo_mode": os.Getenv("VITE_DEMO_MODE") == "true",
"version": "1.0.0",
})
})
// Liveness probe endpoint (for Kubernetes/container orchestration)
r.GET("/live", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "alive", "timestamp": time.Now().Unix()})
})
r.GET("/health", handlers.HealthCheck)
r.GET("/ready", handlers.ReadinessCheck)
r.GET("/live", handlers.LivenessCheck)
r.GET("/api/v1/config", handlers.GetAPIConfig)
// Demo status endpoint
r.GET("/api/demo/status", handlers.DemoStatus)
@@ -363,36 +175,7 @@ func main() {
github.GET("/repos", handlers.GetGitHubRepos)
}
// Public YouTube search test endpoint (no auth required for testing)
v1.POST("/youtube-search-test", func(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
MaxResults int `json:"max_results"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.MaxResults <= 0 {
req.MaxResults = 5
}
// Search videos using the YouTube service
response, err := services.SearchYouTubeVideos(req.Query, req.MaxResults, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to search YouTube videos",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": response,
})
})
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
// Protected auth routes (with demo mode protection)
authProtected := v1.Group("/auth")
@@ -910,41 +693,30 @@ func main() {
}
}
// Start server with graceful shutdown
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Create HTTP server with custom configuration
srv := &http.Server{
Addr: ":" + port,
Addr: ":" + cfg.Server.Port,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
// Start server in a goroutine
go func() {
log.Printf("Server starting on port %s", port)
log.Printf("Server starting on port %s", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Failed to start server:", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Cleanup sessions
middleware.CleanupSessionsOnShutdown()
log.Println("Sessions cleaned up")
// Create a deadline for shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
defer cancel()
// Attempt graceful shutdown
+63
View File
@@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
allowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
ginMode := os.Getenv("GIN_MODE")
if allowedOrigins == "" {
if ginMode == "release" {
c.JSON(http.StatusForbidden, gin.H{
"error": "CORS not configured for production",
"message": "Please set CORS_ALLOWED_ORIGINS environment variable",
})
c.Abort()
return
} else {
allowedOrigins = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
}
}
origin := c.Request.Header.Get("Origin")
allowed := false
if allowedOrigins == "*" {
allowed = true
} else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
allowed = true
break
}
}
}
if allowed {
if allowedOrigins == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+111
View File
@@ -0,0 +1,111 @@
package utils
import (
"fmt"
"net/http"
)
type ErrorCode string
const (
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrBadRequest ErrorCode = "BAD_REQUEST"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrForbidden ErrorCode = "FORBIDDEN"
ErrNotFound ErrorCode = "NOT_FOUND"
ErrConflict ErrorCode = "CONFLICT"
ErrValidation ErrorCode = "VALIDATION_ERROR"
ErrRateLimit ErrorCode = "RATE_LIMIT_EXCEEDED"
ErrServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
)
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
HTTPStatus int `json:"-"`
}
func (e *AppError) Error() string {
if e.Details != "" {
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func NewAppError(code ErrorCode, message string, httpStatus int) *AppError {
return &AppError{
Code: code,
Message: message,
HTTPStatus: httpStatus,
}
}
func NewAppErrorWithDetails(code ErrorCode, message string, httpStatus int, details string) *AppError {
return &AppError{
Code: code,
Message: message,
Details: details,
HTTPStatus: httpStatus,
}
}
func WrapError(err error, code ErrorCode, message string, httpStatus int) *AppError {
return &AppError{
Code: code,
Message: message,
Details: err.Error(),
HTTPStatus: httpStatus,
}
}
func IsAppError(err error) (*AppError, bool) {
if appErr, ok := err.(*AppError); ok {
return appErr, true
}
return nil, false
}
func BadRequest(message string) *AppError {
return NewAppError(ErrBadRequest, message, http.StatusBadRequest)
}
func BadRequestWithDetails(message, details string) *AppError {
return NewAppErrorWithDetails(ErrBadRequest, message, http.StatusBadRequest, details)
}
func Unauthorized(message string) *AppError {
return NewAppError(ErrUnauthorized, message, http.StatusUnauthorized)
}
func Forbidden(message string) *AppError {
return NewAppError(ErrForbidden, message, http.StatusForbidden)
}
func NotFound(message string) *AppError {
return NewAppError(ErrNotFound, message, http.StatusNotFound)
}
func Conflict(message string) *AppError {
return NewAppError(ErrConflict, message, http.StatusConflict)
}
func ValidationErr(message string) *AppError {
return NewAppError(ErrValidation, message, http.StatusBadRequest)
}
func InternalError(message string) *AppError {
return NewAppError(ErrInternal, message, http.StatusInternalServerError)
}
func InternalErrorWithDetails(message, details string) *AppError {
return NewAppErrorWithDetails(ErrInternal, message, http.StatusInternalServerError, details)
}
func RateLimitExceeded(message string) *AppError {
return NewAppError(ErrRateLimit, message, http.StatusTooManyRequests)
}
func ServiceUnavailable(message string) *AppError {
return NewAppError(ErrServiceUnavailable, message, http.StatusServiceUnavailable)
}