package main import ( "bytes" "embed" "encoding/json" "flag" "fmt" "io" "io/fs" "log" "net/http" "net/http/httputil" "net/url" "os" "regexp" "strconv" "strings" "time" ) //go:embed all:static var static embed.FS // Endpoints de EasyAppointments de SOLO LECTURA y no sensibles que el frontend // puede consultar a través del proxy. Las escrituras (crear cliente y cita) y la // búsqueda de clientes NO están aquí: se orquestan server-side en /api/book, de // modo que el navegador nunca puede listar/borrar/modificar datos de EA. var eaAllowedRoutes = []struct { method string path *regexp.Regexp }{ {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/categories/?$`)}, {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/service_categories/?$`)}, {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/services/?$`)}, {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/availabilities/?$`)}, {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/providers/\d+/?$`)}, } func eaRouteAllowed(method, path string) bool { for _, route := range eaAllowedRoutes { if route.method == method && route.path.MatchString(path) { return true } } return false } func writeJSONError(w http.ResponseWriter, status int, code string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) fmt.Fprintf(w, `{"error": %q}`, code) } // flexInt acepta enteros que llegan como número JSON o como string ("30", 30, // 30.0). La API de EasyAppointments mezcla ambos formatos según el campo. type flexInt int func (f *flexInt) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), `"`) if s == "" || s == "null" { *f = 0 return nil } if n, err := strconv.Atoi(s); err == nil { *f = flexInt(n) return nil } ff, err := strconv.ParseFloat(s, 64) if err != nil { return fmt.Errorf("flexInt: valor no numérico %q", s) } *f = flexInt(ff) return nil } // eaClient hace las llamadas server-side a EasyAppointments con las credenciales // admin. EasyAppointments es la ÚNICA fuente de verdad: este servidor no almacena // clientes ni citas, solo orquesta llamadas a su API REST. type eaClient struct { base string apiKey string user string pass string http *http.Client } func newEAClient() *eaClient { base := "http://easyappointments:80" if t := os.Getenv("EA_TARGET"); t != "" { base = t } return &eaClient{ base: strings.TrimRight(base, "/"), apiKey: os.Getenv("EA_API_KEY"), user: os.Getenv("EA_API_USERNAME"), pass: os.Getenv("EA_API_PASSWORD"), http: &http.Client{Timeout: 15 * time.Second}, } } func (c *eaClient) auth(req *http.Request) { if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) } else if c.user != "" && c.pass != "" { req.SetBasicAuth(c.user, c.pass) } } // do llama a la API v1 de EA y decodifica la respuesta JSON en out (si no es nil). // Devuelve error si el status no es 2xx. func (c *eaClient) do(method, path string, body, out interface{}) error { var reader io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return err } reader = bytes.NewReader(buf) } req, err := http.NewRequest(method, c.base+"/index.php/api/v1"+path, reader) if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } c.auth(req) resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("EA %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet))) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } // ---- Tipos de EA usados en la orquestación ---- type eaService struct { ID flexInt `json:"id"` Name string `json:"name"` Duration flexInt `json:"duration"` } type eaCustomer struct { ID flexInt `json:"id,omitempty"` FirstName string `json:"firstName"` LastName string `json:"lastName"` Email string `json:"email"` Phone string `json:"phone"` Notes string `json:"notes"` } type eaAppointment struct { Start string `json:"start"` End string `json:"end"` ServiceID int `json:"serviceId"` ProviderID int `json:"providerId"` CustomerID int `json:"customerId"` Notes string `json:"notes"` Status string `json:"status"` } // ---- Petición de reserva desde el navegador ---- type bookRequest struct { Nombre string `json:"nombre"` Email string `json:"email"` Telefono string `json:"telefono"` Mensaje string `json:"mensaje"` ProviderID int `json:"providerId"` ServiceIDs []int `json:"serviceIds"` Date string `json:"date"` // YYYY-MM-DD Time string `json:"time"` // HH:MM } var ( reDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) reTime = regexp.MustCompile(`^\d{2}:\d{2}$`) ) // handleBook orquesta la reserva contra EasyAppointments: // 1. valida la entrada, // 2. lee los servicios de EA (fuente de verdad de nombre/duración), // 3. busca o crea el cliente por email, // 4. crea UNA cita con el servicio de mayor duración y el bloque total en notas. func handleBook(ea *eaClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } var req bookRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid_json") return } req.Nombre = strings.TrimSpace(req.Nombre) req.Email = strings.TrimSpace(req.Email) req.Telefono = strings.TrimSpace(req.Telefono) req.Mensaje = strings.TrimSpace(req.Mensaje) if req.Nombre == "" || req.Email == "" || req.Telefono == "" || len(req.ServiceIDs) == 0 || req.ProviderID <= 0 || !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) { writeJSONError(w, http.StatusBadRequest, "datos_incompletos") return } // 2. Servicios desde EA: no confiamos en nombres/duraciones del cliente. var services []eaService if err := ea.do(http.MethodGet, "/services", nil, &services); err != nil { log.Printf("book: error leyendo servicios: %v", err) writeJSONError(w, http.StatusBadGateway, "servicios_no_disponibles") return } byID := make(map[int]eaService, len(services)) for _, s := range services { byID[int(s.ID)] = s } var ( chosen []eaService total int primary eaService maxDur = -1 ) for _, id := range req.ServiceIDs { s, ok := byID[id] if !ok { writeJSONError(w, http.StatusBadRequest, "servicio_invalido") return } dur := int(s.Duration) if dur <= 0 { dur = 30 } total += dur chosen = append(chosen, s) if dur > maxDur { maxDur = dur primary = s } } // 3. start/end calculados a partir de la duración total REAL de EA. start, err := time.Parse("2006-01-02 15:04", req.Date+" "+req.Time) if err != nil { writeJSONError(w, http.StatusBadRequest, "fecha_invalida") return } const layout = "2006-01-02 15:04:05" end := start.Add(time.Duration(total) * time.Minute) // 4. notas con el detalle completo (multiservicio). names := make([]string, len(chosen)) for i, s := range chosen { names[i] = s.Name } notesLines := []string{ "Nombre: " + req.Nombre, "Email: " + req.Email, "Teléfono: " + req.Telefono, "Servicios: " + strings.Join(names, " + "), fmt.Sprintf("Duración total: %d min", total), } if req.Mensaje != "" { notesLines = append(notesLines, "Mensaje: "+req.Mensaje) } notes := strings.Join(notesLines, "\n") // 5. cliente: buscar por email o crear (en EA). customerID, err := ea.findOrCreateCustomer(req) if err != nil { log.Printf("book: error con cliente: %v", err) writeJSONError(w, http.StatusBadGateway, "cliente_no_disponible") return } // 6. crear la cita (en EA). appt := eaAppointment{ Start: start.Format(layout), End: end.Format(layout), ServiceID: int(primary.ID), ProviderID: req.ProviderID, CustomerID: customerID, Notes: notes, Status: "booked", } if err := ea.do(http.MethodPost, "/appointments", appt, nil); err != nil { log.Printf("book: error creando cita: %v", err) writeJSONError(w, http.StatusBadGateway, "cita_no_creada") return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) fmt.Fprint(w, `{"ok": true}`) } } // findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea. func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { var found []eaCustomer if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil { for _, cust := range found { if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) { return int(cust.ID), nil } } } first, last := splitName(req.Nombre) var created eaCustomer body := eaCustomer{ FirstName: first, LastName: last, Email: req.Email, Phone: req.Telefono, Notes: "Creado desde web MAY Studio", } if err := c.do(http.MethodPost, "/customers", body, &created); err != nil { return 0, err } if int(created.ID) == 0 { return 0, fmt.Errorf("EA no devolvió id de cliente") } return int(created.ID), nil } func splitName(full string) (first, last string) { parts := strings.Fields(full) switch len(parts) { case 0: return full, "" case 1: return parts[0], "" default: return parts[0], strings.Join(parts[1:], " ") } } func main() { port := flag.Int("port", 8080, "puerto del servidor") flag.Parse() staticFS, err := fs.Sub(static, "static") if err != nil { log.Fatal(err) } ea := newEAClient() // Proxy de SOLO LECTURA a EasyAppointments. // El frontend llama a /ea-api/v1/... (servicios, categorías, proveedor, // disponibilidad) y aquí se reescribe la ruta y se añade la auth admin. eaURL, err := url.Parse(ea.base) if err != nil { log.Fatal(err) } eaProxy := httputil.NewSingleHostReverseProxy(eaURL) // Evita que el navegador muestre el popup de credenciales. // Nunca exponemos WWW-Authenticate ni mensajes de auth del backend. eaProxy.ModifyResponse = func(resp *http.Response) error { resp.Header.Del("Www-Authenticate") // Evitamos que cookies de sesión de EA lleguen al navegador del usuario público resp.Header.Del("Set-Cookie") // Limpiamos cabeceras que revelan el backend resp.Header.Del("Server") resp.Header.Del("X-Powered-By") if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { resp.StatusCode = http.StatusBadGateway resp.Status = http.StatusText(http.StatusBadGateway) // Reemplazamos el body para no filtrar detalles del backend resp.Body.Close() resp.Body = io.NopCloser(strings.NewReader(`{"error": "service_unavailable"}`)) resp.ContentLength = -1 resp.Header.Set("Content-Type", "application/json") resp.Header.Del("Content-Length") } return nil } http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) { // Allowlist método+ruta ANTES de inyectar credenciales: solo lecturas. if !eaRouteAllowed(r.Method, r.URL.Path) { writeJSONError(w, http.StatusNotFound, "not_found") return } // /ea-api/v1/services → /index.php/api/v1/services r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1) // Auth (server-side, nunca se expone al navegador) if ea.apiKey != "" { r.Header.Set("Authorization", "Bearer "+ea.apiKey) } else if ea.user != "" && ea.pass != "" { r.SetBasicAuth(ea.user, ea.pass) } eaProxy.ServeHTTP(w, r) }) // Reservas: orquestadas server-side contra EasyAppointments. http.HandleFunc("/api/book", handleBook(ea)) http.Handle("/", http.FileServer(http.FS(staticFS))) addr := fmt.Sprintf(":%d", *port) log.Printf("Servidor iniciado en http://localhost%s", addr) log.Fatal(http.ListenAndServe(addr, nil)) }