feat(backend): implement HTTP API, auth, chat, and admin surfaces
Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pargar/backend/internal/observability"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Hijack unwraps the underlying ResponseWriter so WebSocket upgrades work.
|
||||
// Without this, gorilla/websocket Upgrade fails with HTTP 500.
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
h, ok := r.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("response writer does not support hijacking")
|
||||
}
|
||||
// gorilla writes the 101 status line on the raw conn after Hijack, so
|
||||
// WriteHeader is never called — record Switching Protocols for access logs.
|
||||
if r.status == http.StatusOK {
|
||||
r.status = http.StatusSwitchingProtocols
|
||||
}
|
||||
return h.Hijack()
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return r.ResponseWriter
|
||||
}
|
||||
|
||||
func requestMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
reqID := r.Header.Get("X-Request-ID")
|
||||
if reqID == "" {
|
||||
reqID = newRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", reqID)
|
||||
|
||||
meta := &requestMeta{}
|
||||
ctx := context.WithValue(r.Context(), ctxMeta, meta)
|
||||
ctx, span := observability.StartSpan(ctx, r.Method+" "+routeLabel(r))
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
path := routeLabel(r)
|
||||
observability.ObserveHTTP(r.Method, path, rec.status, time.Since(start))
|
||||
|
||||
observability.L().Info("http",
|
||||
"request_id", reqID,
|
||||
"method", r.Method,
|
||||
"path", path,
|
||||
"status", rec.status,
|
||||
"duration_ms", observability.DurationMs(start),
|
||||
"user_id", meta.UserID,
|
||||
"remote", clientAddress(r),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func routeLabel(r *http.Request) string {
|
||||
p := r.URL.Path
|
||||
// Collapse numeric IDs and secret path tokens for cardinality-safe,
|
||||
// non-leaking metrics/access logs.
|
||||
parts := strings.Split(p, "/")
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
prev := ""
|
||||
if i > 0 {
|
||||
prev = parts[i-1]
|
||||
}
|
||||
// Capability / invite tokens must never appear in logs or Prometheus labels.
|
||||
if prev == "chats" && i >= 2 && parts[i-2] == "exports" {
|
||||
parts[i] = "{token}"
|
||||
continue
|
||||
}
|
||||
if prev == "register-invite" {
|
||||
parts[i] = "{token}"
|
||||
continue
|
||||
}
|
||||
allDigit := true
|
||||
for _, c := range part {
|
||||
if c < '0' || c > '9' {
|
||||
allDigit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigit {
|
||||
parts[i] = "{id}"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
Reference in New Issue
Block a user