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,228 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user