285 lines
9.4 KiB
Go
285 lines
9.4 KiB
Go
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
|
|
}
|