283 lines
7.1 KiB
Go
283 lines
7.1 KiB
Go
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"`
|
|
}
|
|
|
|
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("/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) 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
|
|
}
|