Initial commit
This commit is contained in:
206
server/internal/httpapi/server.go
Normal file
206
server/internal/httpapi/server.go
Normal file
@@ -0,0 +1,206 @@
|
||||
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
|
||||
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),
|
||||
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/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
|
||||
}
|
||||
Reference in New Issue
Block a user