Files
pargar/backend/internal/api/server.go
T
ahp 422e6ed289 feat(backend): implement HTTP API, auth, chat, and admin surfaces
Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
2026-10-07 19:40:26 +03:30

229 lines
6.4 KiB
Go

package api
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
"github.com/rs/cors"
"pargar/backend/internal/config"
"pargar/backend/internal/observability"
"pargar/backend/internal/service"
"pargar/backend/internal/storage"
"pargar/backend/internal/store"
)
type Server struct {
cfg *config.Config
store *store.Store
redis *redis.Client
hub *Hub
watch *service.WatchService
hearts *service.HeartsService
notify *service.Notifier
storage storage.Storage
auth *AuthService
}
func NewServer(cfg *config.Config, st *store.Store, rdb *redis.Client, stg storage.Storage) *Server {
hub := NewHub(rdb)
notifier := service.NewNotifier(st, hub)
trustedProxyConfig = cfg
return &Server{
cfg: cfg,
store: st,
redis: rdb,
hub: hub,
watch: service.NewWatchService(st),
hearts: service.NewHeartsService(st, cfg),
notify: notifier,
storage: stg,
auth: NewAuthService(cfg, st),
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
router := newRouter(s)
// Global per-IP limit on API routes. /api/health is short-circuited in ping()
// before this mux, so probes are not counted or blocked.
mux.Handle("/api/", s.globalAPIRateLimit(router))
mux.HandleFunc("/media/", s.handleMedia)
mux.Handle("/metrics", s.metricsAuth(observability.MetricsHandler()))
c := cors.New(cors.Options{
AllowedOrigins: s.cfg.AllowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-ID", "X-Metrics-Token"},
ExposedHeaders: []string{"Retry-After", "X-Request-ID"},
AllowCredentials: true,
MaxAge: 300,
})
return c.Handler(s.recoverPanic(requestMiddleware(ping(mux))))
}
// metricsAuth requires METRICS_TOKEN (Bearer or X-Metrics-Token). When the token
// is unset outside DevMode, the endpoint is disabled (404) so it cannot be scraped.
func (s *Server) metricsAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := strings.TrimSpace(s.cfg.MetricsToken)
if want == "" {
if s.cfg.DevMode {
next.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
return
}
got := strings.TrimSpace(r.Header.Get("X-Metrics-Token"))
if got == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
got = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
}
}
if got == "" || got != want {
writeErr(w, http.StatusUnauthorized, "دسترسی به متریک مجاز نیست")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
key, err := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/media/"))
if err != nil || key == "" || strings.Contains(key, "..") {
http.NotFound(w, r)
return
}
key = strings.TrimPrefix(key, "/")
q := r.URL.Query()
// Always require a valid signature — no cookie-only fallback (blocks easy link download).
if !s.verifyMediaSignature(key, q.Get("exp"), q.Get("sig")) {
writeErr(w, http.StatusUnauthorized, "دسترسی به رسانه مجاز نیست")
return
}
if err := s.storage.Serve(w, r, key); err != nil {
http.NotFound(w, r)
return
}
}
func (s *Server) handleSignMedia(w http.ResponseWriter, r *http.Request) {
key := strings.TrimSpace(r.URL.Query().Get("key"))
key = strings.TrimPrefix(key, "/")
if key == "" || strings.Contains(key, "..") {
writeErr(w, http.StatusBadRequest, "کلید رسانه نامعتبر است")
return
}
writeJSON(w, http.StatusOK, map[string]any{"url": s.signMediaURL(key)})
}
func ping(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/health" {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
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 rec := recover(); rec != nil {
observability.L().Error("panic",
"error", rec,
"path", r.URL.Path,
"request_id", requestIDFromWriter(w),
)
writeErrCode(w, http.StatusInternalServerError, CodeInternal, "خطای داخلی سامانه")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) wsCheckOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser clients
}
origin = strings.TrimRight(origin, "/")
for _, allowed := range s.cfg.AllowedOrigins() {
if origin == allowed {
return true
}
}
return false
}
func (s *Server) newUpgrader() websocket.Upgrader {
return websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: s.wsCheckOrigin,
}
}
func (s *Server) clientAddress(r *http.Request) string {
return clientAddressTrusted(r, s.cfg)
}
// clientAddressTrusted returns the client IP. X-Real-IP / X-Forwarded-For are
// honoured only when the immediate TCP peer is in TrustedProxyCIDRs (Traefik must
// overwrite those headers). Otherwise RemoteAddr is used.
func clientAddressTrusted(r *http.Request, cfg *config.Config) string {
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteHost = r.RemoteAddr
}
remoteIP := net.ParseIP(remoteHost)
trusted := cfg != nil && cfg.IsTrustedProxy(remoteIP)
if trusted {
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
if ip := net.ParseIP(xri); ip != nil {
return ip.String()
}
return xri
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ipStr := strings.TrimSpace(parts[i])
if ipStr == "" {
continue
}
if ip := net.ParseIP(ipStr); ip != nil {
return ip.String()
}
return ipStr
}
}
}
if remoteHost != "" {
return remoteHost
}
return r.RemoteAddr
}
// trustedProxyConfig is set by NewServer so package-level helpers honour the same CIDRs.
var trustedProxyConfig *config.Config
func clientAddress(r *http.Request) string {
return clientAddressTrusted(r, trustedProxyConfig)
}
// SetTrustedProxyConfigForTest replaces the package-level proxy config (tests only).
func SetTrustedProxyConfigForTest(cfg *config.Config) (restore func()) {
prev := trustedProxyConfig
trustedProxyConfig = cfg
return func() { trustedProxyConfig = prev }
}