package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "os/exec" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/joho/godotenv" ) type server struct { pool *pgxpool.Pool mu sync.Mutex seeding bool } type seedRequest struct { Type string `json:"type"` Password string `json:"password"` } type healthResponse struct { Status string `json:"status"` Database string `json:"database"` Version string `json:"version"` Error string `json:"error,omitempty"` } type seedResponse struct { Message string `json:"message"` Type string `json:"type,omitempty"` } type rootResponse struct { Docs string `json:"docs"` Health string `json:"health"` IP string `json:"ip"` Message string `json:"message"` Version string `json:"version"` } func main() { _ = godotenv.Load() databaseURL, err := buildDatabaseURL() if err != nil { log.Fatalf("❌ Failed to build database URL: %v", err) } pool, err := pgxpool.New(context.Background(), databaseURL) if err != nil { log.Fatalf("❌ Failed to create database pool: %v", err) } defer pool.Close() logDatabaseConfig(databaseURL) srv := &server{pool: pool} mux := http.NewServeMux() mux.HandleFunc("/", srv.rootHandler) mux.HandleFunc("/health", srv.healthHandler) mux.HandleFunc("/seed", srv.seedHandler) mux.HandleFunc("/reset", srv.resetHandler) handler := withCORS(withLogging(mux)) port := getenv("PORT", "8080") log.Printf("🌱 GoHorseJobs Seeder API listening on port %s", port) log.Printf(" Health check: http://localhost:%s/health", port) if err := http.ListenAndServe(":"+port, handler); err != nil { log.Fatalf("❌ Server failed: %v", err) } } func (s *server) rootHandler(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } ip := getClientIP(r) writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "🌱 GoHorseJobs Seeder API is running!", "version": "1.0.0", "ip": ip, "endpoints": map[string]string{ "health": "/health", "seed": "POST /seed", "reset": "POST /reset", }, }) } func (s *server) healthHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, healthResponse{Status: "error", Database: "disconnected", Error: "Method not allowed"}) return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() var result int if err := s.pool.QueryRow(ctx, "SELECT 1").Scan(&result); err != nil { log.Printf("Health check failed: %v", err) writeJSON(w, http.StatusInternalServerError, healthResponse{Status: "error", Database: "disconnected", Error: err.Error()}) return } writeJSON(w, http.StatusOK, healthResponse{Status: "ok", Database: "connected", Version: "1.0.0"}) } func (s *server) seedHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) return } var req seedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON body"}) return } if !isAuthorized(req.Password) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) return } if !s.tryStartSeeding() { writeJSON(w, http.StatusConflict, map[string]string{"error": "Seeding already in progress"}) return } seedType := strings.TrimSpace(req.Type) if seedType == "" { seedType = "full" } writeJSON(w, http.StatusOK, seedResponse{Message: "Seeding started", Type: seedType}) go func() { defer s.finishSeeding() log.Printf("🚀 Starting manual seed (%s)...", seedType) if err := runSeed(seedType); err != nil { log.Printf("❌ Manual seed failed: %v", err) return } log.Printf("✅ Manual seed completed") }() } func (s *server) resetHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) return } var req seedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON body"}) return } if !isAuthorized(req.Password) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) return } if !s.tryStartSeeding() { writeJSON(w, http.StatusConflict, map[string]string{"error": "Seeding/Reset in progress"}) return } writeJSON(w, http.StatusOK, seedResponse{Message: "Reset started"}) go func() { defer s.finishSeeding() log.Printf("🚀 Starting manual reset...") if err := runReset(); err != nil { log.Printf("❌ Manual reset failed: %v", err) return } log.Printf("✅ Manual reset completed") }() } func (s *server) tryStartSeeding() bool { s.mu.Lock() defer s.mu.Unlock() if s.seeding { return false } s.seeding = true return true } func (s *server) finishSeeding() { s.mu.Lock() s.seeding = false s.mu.Unlock() } func runSeed(seedType string) error { args := []string{"src/index.js"} switch seedType { case "lite": args = append(args, "--lite") case "no-locations": args = append(args, "--skip-locations") case "full": default: log.Printf("⚠️ Unknown seed type '%s', using full", seedType) } return runNodeCommand(args...) } func runReset() error { return runNodeCommand("src/index.js", "--reset") } func runNodeCommand(args ...string) error { cmd := exec.Command("node", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = os.Environ() return cmd.Run() } func buildDatabaseURL() (string, error) { if databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")); databaseURL != "" { cleaned := strings.ReplaceAll(databaseURL, "?sslmode=require", "") cleaned = strings.ReplaceAll(cleaned, "&sslmode=require", "") return cleaned, nil } host := getenv("DB_HOST", "localhost") port := getenv("DB_PORT", "5432") user := getenv("DB_USER", "postgres") password := getenv("DB_PASSWORD", "postgres") name := getenv("DB_NAME", "gohorsejobs") sslmode := getenv("DB_SSLMODE", "disable") if sslmode == "" { sslmode = "disable" } return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", url.QueryEscape(user), url.QueryEscape(password), host, port, name, sslmode), nil } func logDatabaseConfig(databaseURL string) { masked := databaseURL if at := strings.Index(masked, "@"); at != -1 { masked = "postgres://***@" + masked[at+1:] } log.Printf("🔌 DB Config: connectionString=%s", masked) } func isAuthorized(password string) bool { expected := strings.TrimSpace(os.Getenv("SEED_PASSWORD")) if expected == "" { return true } return password == expected } func withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("[%s] %s %s", time.Now().Format(time.RFC3339), r.Method, r.URL.Path) next.ServeHTTP(w, r) }) } func withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func getenv(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func getClientIP(r *http.Request) string { if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { parts := strings.Split(forwarded, ",") return strings.TrimSpace(parts[0]) } if realIP := r.Header.Get("X-Real-IP"); realIP != "" { return realIP } host := r.RemoteAddr if colonPos := strings.LastIndex(host, ":"); colonPos != -1 { return host[:colonPos] } return host }