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 publicTicketLimiter *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), publicTicketLimiter: newQueryLimiter(now, 20, 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("GET /api/public/projects", s.publicProjects) mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket) mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.Handle("POST /api/internal/status/search", s.requireInternalNetwork(http.HandlerFunc(s.internalStatusByPhone))) 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))) mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets))) mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket))) mux.Handle("GET /api/admin/history/batches", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatches))) mux.Handle("GET /api/admin/history/batches/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryBatch))) mux.Handle("GET /api/admin/history/summary", s.requireAdmin(http.HandlerFunc(s.adminHistorySummary))) mux.Handle("GET /api/admin/history/export.csv", s.requireAdmin(http.HandlerFunc(s.adminHistoryExport))) return s.requestID(s.accessLog(s.recoverPanic(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 wroteHeader bool body *logBodyCapture } func (r *statusRecorder) WriteHeader(status int) { if r.wroteHeader { return } r.status = status r.wroteHeader = true r.ResponseWriter.WriteHeader(status) } func (r *statusRecorder) Write(value []byte) (int, error) { if !r.wroteHeader { r.WriteHeader(http.StatusOK) } n, err := r.ResponseWriter.Write(value) if n > 0 && r.body != nil { _, _ = r.body.Write(value[:n]) } return n, err } func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } func (r *statusRecorder) responseStarted() bool { return r.wroteHeader } type flushStatusRecorder struct { *statusRecorder } func (r *flushStatusRecorder) Flush() { if !r.wroteHeader { r.WriteHeader(http.StatusOK) } r.ResponseWriter.(http.Flusher).Flush() } func (s *Server) accessLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := s.now() var requestBody *logBodyCapture var responseBody *logBodyCapture if shouldCaptureLogBody(r) { requestBody = &logBodyCapture{} responseBody = &logBodyCapture{} if r.Body != nil { r.Body = &logCaptureReadCloser{ReadCloser: r.Body, capture: requestBody} } } recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK, body: responseBody} var responseWriter http.ResponseWriter = recorder if _, ok := w.(http.Flusher); ok { responseWriter = &flushStatusRecorder{statusRecorder: recorder} } next.ServeHTTP(responseWriter, r) attributes := []any{ "request_id", requestID(r.Context()), "method", r.Method, "path", logPath(r), "status", recorder.status, "duration_ms", time.Since(started).Milliseconds(), } if isImportantLogRoute(r) { if request := requestLogDetails(r, requestBody); request != nil { attributes = append(attributes, "request", request) } if response := responseLogDetails(r, recorder); response != nil { attributes = append(attributes, "response", response) } } s.logger.Info("http request", attributes...) }) } 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 { responseStarted := false if state, ok := w.(interface{ responseStarted() bool }); ok { responseStarted = state.responseStarted() } s.logger.Error("panic recovered", "request_id", requestID(r.Context()), "error", recovered, "response_started", responseStarted, "stack", string(debug.Stack()), ) if responseStarted { return } 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 }