package httpapi import ( "context" "fmt" "log/slog" "net" "net/http" "runtime/debug" "strings" "time" "calllinesystem/server/internal/config" "calllinesystem/server/internal/database" "calllinesystem/server/internal/model" "calllinesystem/server/internal/security" "github.com/google/uuid" "gorm.io/gorm" ) type contextKey string const ( principalKey contextKey = "principal" requestIDKey contextKey = "request_id" ) type Server struct { db *gorm.DB config config.Config cipher *security.Cipher logger *slog.Logger hub eventPublisher loginLimiter *loginLimiter publicQueryLimiter *queryLimiter dummyPassword string now func() time.Time } func New(db *gorm.DB, cfg config.Config, logger *slog.Logger) (*Server, error) { return NewWithEventPublisher(db, cfg, logger, newEventHub()) } func NewWithEventPublisher(db *gorm.DB, cfg config.Config, logger *slog.Logger, publisher eventPublisher) (*Server, error) { fieldCipher, err := security.NewCipher(cfg.EncryptionKey, cfg.PhoneHMACKey) if err != nil { return nil, err } if publisher == nil { return nil, fmt.Errorf("event publisher is required") } dummy, err := security.HashPassword("not-a-real-password-value") if err != nil { return nil, err } now := func() time.Time { return time.Now().UTC() } return &Server{ db: db, config: cfg, cipher: fieldCipher, logger: logger, hub: publisher, loginLimiter: newLoginLimiter(now), publicQueryLimiter: newQueryLimiter(now, 120, time.Minute), dummyPassword: dummy, now: now, }, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /readyz", s.ready) mux.HandleFunc("POST /api/staff/auth/login", s.loginFor(model.RoleStaff)) mux.Handle("POST /api/staff/auth/logout", s.requireStaff(s.logoutFor(model.RoleStaff))) mux.Handle("GET /api/staff/auth/me", s.requireStaff(http.HandlerFunc(s.me))) mux.HandleFunc("POST /api/admin/auth/login", s.loginFor(model.RoleAdmin)) mux.Handle("POST /api/admin/auth/logout", s.requireAdmin(s.logoutFor(model.RoleAdmin))) mux.Handle("GET /api/admin/auth/me", s.requireAdmin(http.HandlerFunc(s.me))) mux.Handle("GET /api/staff/projects", s.requireStaff(http.HandlerFunc(s.staffProjects))) mux.Handle("GET /api/staff/projects/{id}/queue", s.requireStaff(http.HandlerFunc(s.queueSnapshot))) mux.Handle("POST /api/staff/projects/{id}/tickets", s.requireStaff(http.HandlerFunc(s.createTicket))) mux.Handle("POST /api/staff/projects/{id}/call-next", s.requireStaff(http.HandlerFunc(s.callNext))) mux.HandleFunc("GET /api/public/status/{token}", s.publicStatus) mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot) mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events))) mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview))) mux.Handle("GET /api/admin/users", s.requireAdmin(http.HandlerFunc(s.adminUsers))) mux.Handle("POST /api/admin/users", s.requireAdmin(http.HandlerFunc(s.createAdminUser))) mux.Handle("PUT /api/admin/users/{id}", s.requireAdmin(http.HandlerFunc(s.updateAdminUser))) mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject))) mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject))) mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings))) return s.recoverPanic(s.requestID(s.accessLog(s.securityHeaders(mux)))) } func (s *Server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": s.now()}) } func (s *Server) ready(w http.ResponseWriter, r *http.Request) { sqlDB, err := s.db.DB() if err != nil { writeError(w, err) return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := sqlDB.PingContext(ctx); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"}) return } if err := database.SchemaReady(ctx, sqlDB); err != nil { s.logger.Warn("database schema is not ready", "error", err) writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready"}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "ready"}) } func (s *Server) requestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")) if len(requestID) < 8 || len(requestID) > 80 { requestID = uuid.NewString() } w.Header().Set("X-Request-ID", requestID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, requestID))) }) } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(status int) { r.status = status r.ResponseWriter.WriteHeader(status) } func (r *statusRecorder) Flush() { if flusher, ok := r.ResponseWriter.(http.Flusher); ok { flusher.Flush() } } func (s *Server) accessLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := s.now() recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(recorder, r) s.logger.Info("http request", "request_id", requestID(r.Context()), "method", r.Method, "path", r.URL.Path, "status", recorder.status, "duration_ms", time.Since(started).Milliseconds(), ) }) } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) } func (s *Server) recoverPanic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if recovered := recover(); recovered != nil { s.logger.Error("panic recovered", "request_id", requestID(r.Context()), "error", recovered, "stack", string(debug.Stack())) writeError(w, fmt.Errorf("panic: %v", recovered)) } }() next.ServeHTTP(w, r) }) } func requestID(ctx context.Context) string { value, _ := ctx.Value(requestIDKey).(string) return value } func remoteIP(r *http.Request) *string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if net.ParseIP(host) == nil { return nil } return &host } func boundedUserAgent(r *http.Request) string { value := r.UserAgent() if len(value) > 512 { return value[:512] } return value }