package auth import ( "context" "encoding/json" "errors" "log/slog" "net/http" "regexp" "strings" "time" "hakemsho/internal/httpx" "hakemsho/internal/user" ) // Handler endpointهای احراز هویت را فراهم می‌کند. type Handler struct { users *user.Repo otp *OTPStore kave *Kavenegar jwt *JWT throttle *httpx.Throttle adminMobile string adminOTP string } func NewHandler(users *user.Repo, otp *OTPStore, kave *Kavenegar, jwt *JWT, adminMobile, adminOTP string) *Handler { return &Handler{ users: users, otp: otp, kave: kave, jwt: jwt, throttle: httpx.NewThrottle(3, time.Minute), // معادل throttle:3,1 adminMobile: adminMobile, adminOTP: adminOTP, } } // mobileRe اعتبارسنجی ساده شماره موبایل ایران. var mobileRe = regexp.MustCompile(`^09\d{9}$`) type loginOTPReq struct { Mobile string `json:"mobile"` FCMToken string `json:"fcm_token"` } // LoginOTP — POST /api/auth/login-otp func (h *Handler) LoginOTP(w http.ResponseWriter, r *http.Request) { var req loginOTPReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if !mobileRe.MatchString(req.Mobile) { httpx.Error(w, http.StatusUnprocessableEntity, "invalid mobile") return } // throttle بر اساس موبایل: حداکثر ۳ بار در دقیقه if !h.throttle.Allow(req.Mobile) { httpx.Error(w, http.StatusTooManyRequests, "too many requests") return } ctx := r.Context() u, err := h.users.FindOrCreate(ctx, req.Mobile) if err != nil { slog.Error("find or create user", "err", err) httpx.Error(w, http.StatusInternalServerError, "server error") return } // شماره ادمین کد ثابت دارد و SMS نمی‌گیرد if req.Mobile != h.adminMobile { code := Generate() if err := h.otp.Create(ctx, u.ID, code); err != nil { slog.Error("create otp", "err", err) httpx.Error(w, http.StatusInternalServerError, "server error") return } // ارسال SMS به‌صورت fire-and-forget (بدون صف جانبی، مطابق پلن مینیموم) go func(mobile, code string) { bg, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.kave.SendVerify(bg, mobile, code); err != nil { slog.Error("kavenegar send", "mobile", mobile, "err", err) } }(req.Mobile, code) } httpx.JSON(w, http.StatusOK, map[string]string{"message": "otp sent"}) } type checkOTPReq struct { Mobile string `json:"mobile"` Token string `json:"token"` } // CheckOTP — POST /api/auth/check-otp func (h *Handler) CheckOTP(w http.ResponseWriter, r *http.Request) { var req checkOTPReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid body") return } if req.Mobile == "" || req.Token == "" { httpx.Error(w, http.StatusUnprocessableEntity, "mobile and token required") return } ctx := r.Context() u, err := h.users.FindByMobile(ctx, req.Mobile) if errors.Is(err, user.ErrNotFound) { httpx.Error(w, http.StatusNotFound, "user not found") return } if err != nil { httpx.Error(w, http.StatusInternalServerError, "server error") return } // راه ادمین برای تست بدون SMS adminBypass := h.adminMobile != "" && req.Mobile == h.adminMobile && req.Token == h.adminOTP if !adminBypass { if err := h.otp.Verify(ctx, u.ID, req.Token); err != nil { httpx.Error(w, http.StatusBadRequest, "token not valid") return } } ability := "user" if u.IsAdmin { ability = "admin" } token, err := h.jwt.Issue(u.ID, ability) if err != nil { httpx.Error(w, http.StatusInternalServerError, "server error") return } httpx.JSON(w, http.StatusOK, map[string]any{ "user": u, "token": token, }) } // Me — GET /api/me (نیازمند JWT) func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { id, ok := UserID(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized") return } u, err := h.users.FindByID(r.Context(), id) if err != nil { httpx.Error(w, http.StatusNotFound, "user not found") return } httpx.JSON(w, http.StatusOK, map[string]any{"user": u}) } type profileReq struct { FirstName string `json:"first_name"` Avatar string `json:"avatar"` } // UpdateProfile — POST /api/profile (تنظیم نام نمایشی و آواتار، نیازمند JWT) func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { id, ok := UserID(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized") return } var req profileReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid body") return } name := strings.TrimSpace(req.FirstName) if len([]rune(name)) < 2 || len([]rune(name)) > 20 { httpx.Error(w, http.StatusUnprocessableEntity, "name must be 2-20 characters") return } if err := h.users.UpdateProfile(r.Context(), id, name, req.Avatar); err != nil { httpx.Error(w, http.StatusInternalServerError, "server error") return } u, _ := h.users.FindByID(r.Context(), id) httpx.JSON(w, http.StatusOK, map[string]any{"user": u}) }