package main import ( "bytes" "embed" "encoding/json" "flag" "fmt" "io" "io/fs" "log" "net/http" "net/http/httputil" "net/url" "os" "regexp" "strconv" "strings" "sync" "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+/?$`)}, // Solo este ajuste concreto (horario del negocio); el resto de /settings // sigue bloqueado porque contiene datos sensibles. {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/settings/company_working_plan/?$`)}, } 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,omitempty"` Phone string `json:"phone"` Notes string `json:"notes"` } type eaProvider struct { ID flexInt `json:"id"` } 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"` Apellidos string `json:"apellidos"` Email string `json:"email"` Telefono string `json:"telefono"` Mensaje string `json:"mensaje"` 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. resuelve el provider (server-side, ver providerID), // 4. busca o crea el cliente por teléfono (es el dato que lo identifica; el email es opcional), // 5. 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.Apellidos = strings.TrimSpace(req.Apellidos) req.Email = strings.TrimSpace(req.Email) req.Telefono = strings.TrimSpace(req.Telefono) req.Mensaje = strings.TrimSpace(req.Mensaje) if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" || len(req.ServiceIDs) == 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 + " " + req.Apellidos, "Teléfono: " + req.Telefono, } if req.Email != "" { notesLines = append(notesLines, "Email: "+req.Email) } notesLines = append(notesLines, "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. provider resuelto server-side: el navegador no elige contra quién reserva. providerID, err := ea.providerID() if err != nil { log.Printf("book: error resolviendo provider: %v", err) writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") return } // 6. cliente: buscar por teléfono 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 } // 7. crear la cita (en EA). appt := eaAppointment{ Start: start.Format(layout), End: end.Format(layout), ServiceID: int(primary.ID), ProviderID: 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}`) } } // providerID resuelve el id del provider contra el que se reserva. Con // EA_PROVIDER_ID definido se usa ese valor; si no, se pregunta a EA y se toma // el primero (el salón tiene un único provider). Así el id no va hardcodeado // y cada entorno (desarrollo, producción) resuelve el suyo. func (c *eaClient) providerID() (int, error) { if v := os.Getenv("EA_PROVIDER_ID"); v != "" { n, err := strconv.Atoi(v) if err != nil || n <= 0 { return 0, fmt.Errorf("EA_PROVIDER_ID inválido: %q", v) } return n, nil } var provs []eaProvider if err := c.do(http.MethodGet, "/providers", nil, &provs); err != nil { return 0, err } if len(provs) == 0 { return 0, fmt.Errorf("EA no tiene ningún provider") } if len(provs) > 1 { log.Printf("aviso: EA tiene %d providers; se usa el primero (id %d). Fija EA_PROVIDER_ID para elegir otro.", len(provs), int(provs[0].ID)) } return int(provs[0].ID), nil } // handleConfig expone al frontend la configuración mínima que necesita para // consultar disponibilidad por el proxy: solo el id del provider. func handleConfig(ea *eaClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } id, err := ea.providerID() if err != nil { log.Printf("config: error resolviendo provider: %v", err) writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"providerId": %d}`, id) } } // normPhone deja solo los dígitos ("690 91 88 15" → "690918815") para que el // mismo número escrito de formas distintas identifique al mismo cliente. func normPhone(s string) string { var b strings.Builder for _, r := range s { if r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } // samePhone compara dos teléfonos por sus dígitos, tolerando prefijos de país: // "+34 690918815" y "690918815" son el mismo número (sufijo común de ≥9 dígitos). func samePhone(a, b string) bool { a, b = normPhone(a), normPhone(b) if a == "" || b == "" { return false } if len(a) > len(b) { a, b = b, a } return a == b || (len(a) >= 9 && strings.HasSuffix(b, a)) } // handleMonthAvailability devuelve los días de un mes sin NINGÚN hueco libre // para un servicio ("unavailableDays"), para que el calendario los deshabilite // sin que el usuario tenga que pulsarlos. Consulta las availabilities de EA // día a día (en paralelo, saltando los pasados) y cachea el resultado 10 min // por mes+servicio para no repetir ~30 llamadas a EA en cada render. func handleMonthAvailability(ea *eaClient) http.HandlerFunc { type cacheEntry struct { expires time.Time days []string } var ( mu sync.Mutex cache = make(map[string]cacheEntry) ) return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } month := r.URL.Query().Get("month") first, errMonth := time.Parse("2006-01", month) serviceID, errService := strconv.Atoi(r.URL.Query().Get("serviceId")) // Solo del mes actual a 12 meses vista: es lo único que el calendario // puede pedir y acota el abanico de llamadas a EA. now := time.Now() currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) if errMonth != nil || errService != nil || serviceID <= 0 || first.Before(currentMonth) || first.After(currentMonth.AddDate(1, 0, 0)) { writeJSONError(w, http.StatusBadRequest, "parametros_invalidos") return } key := fmt.Sprintf("%s:%d", month, serviceID) mu.Lock() e, hit := cache[key] mu.Unlock() if !hit || !now.Before(e.expires) { providerID, err := ea.providerID() if err != nil { log.Printf("month-availability: error resolviendo provider: %v", err) writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") return } daysInMonth := first.AddDate(0, 1, -1).Day() today := now.Format("2006-01-02") unavailable := make([]bool, daysInMonth+1) var wg sync.WaitGroup sem := make(chan struct{}, 6) // máx. 6 llamadas a EA en paralelo for d := 1; d <= daysInMonth; d++ { date := fmt.Sprintf("%s-%02d", month, d) if date < today { // los días pasados ya los bloquea el navegador continue } wg.Add(1) go func(d int, date string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() var slots []string path := fmt.Sprintf("/availabilities?providerId=%d&serviceId=%d&date=%s", providerID, serviceID, date) // Si EA falla, el día se deja clicable: al seleccionarlo // se consulta de nuevo y allí se informa del error. if err := ea.do(http.MethodGet, path, nil, &slots); err == nil && len(slots) == 0 { unavailable[d] = true } }(d, date) } wg.Wait() days := []string{} for d := 1; d <= daysInMonth; d++ { if unavailable[d] { days = append(days, fmt.Sprintf("%s-%02d", month, d)) } } e = cacheEntry{expires: time.Now().Add(10 * time.Minute), days: days} mu.Lock() cache[key] = e mu.Unlock() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string][]string{"unavailableDays": e.days}) } } // findOrCreateCustomer busca el cliente por TELÉFONO en EA (es el dato que lo // identifica; el email es opcional) y, si no existe, lo crea. Así todas las // citas hechas con el mismo teléfono quedan asignadas al mismo cliente. func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { phone := normPhone(req.Telefono) // La búsqueda usa los últimos 9 dígitos (número nacional) para encontrar // también registros guardados con prefijo de país. q := phone if len(q) > 9 { q = q[len(q)-9:] } var found []eaCustomer if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil { for _, cust := range found { if samePhone(cust.Phone, phone) { return int(cust.ID), nil } } } var created eaCustomer body := eaCustomer{ FirstName: req.Nombre, LastName: req.Apellidos, Email: req.Email, Phone: phone, 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 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)) // Config para el frontend: el id del provider se resuelve aquí (EA o // EA_PROVIDER_ID), nunca va hardcodeado en el HTML. http.HandleFunc("/api/config", handleConfig(ea)) // Días del mes visible sin ningún hueco libre: el calendario los pinta // deshabilitados para ahorrar clics en días completos o festivos. http.HandleFunc("/api/month-availability", handleMonthAvailability(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)) }