From 422e6ed289d9a2a8efae3e5ceafb051b9e86400d Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Wed, 7 Oct 2026 19:40:26 +0330 Subject: [PATCH] feat(backend): implement HTTP API, auth, chat, and admin surfaces Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration. --- backend/internal/api/admin.go | 890 ++++++++++++++++++++ backend/internal/api/api_test.go | 295 +++++++ backend/internal/api/auth.go | 342 ++++++++ backend/internal/api/blacklist.go | 155 ++++ backend/internal/api/blacklist_test.go | 349 ++++++++ backend/internal/api/captcha.go | 350 ++++++++ backend/internal/api/captcha_test.go | 134 +++ backend/internal/api/certificate.go | 288 +++++++ backend/internal/api/challenges.go | 108 +++ backend/internal/api/chat.go | 512 +++++++++++ backend/internal/api/chat_export.go | 619 ++++++++++++++ backend/internal/api/chat_export_test.go | 398 +++++++++ backend/internal/api/community.go | 65 ++ backend/internal/api/community_test.go | 31 + backend/internal/api/errors.go | 103 +++ backend/internal/api/errors_test.go | 63 ++ backend/internal/api/events.go | 130 +++ backend/internal/api/events_test.go | 101 +++ backend/internal/api/flow_test.go | 602 +++++++++++++ backend/internal/api/http_middleware.go | 125 +++ backend/internal/api/identity_test.go | 223 +++++ backend/internal/api/invites.go | 464 ++++++++++ backend/internal/api/invites_test.go | 390 +++++++++ backend/internal/api/leaderboard.go | 94 +++ backend/internal/api/learning.go | 42 + backend/internal/api/media_sign.go | 96 +++ backend/internal/api/media_sign_test.go | 76 ++ backend/internal/api/middleware.go | 91 ++ backend/internal/api/notifications.go | 97 +++ backend/internal/api/pagination.go | 66 ++ backend/internal/api/prod_hardening_test.go | 115 +++ backend/internal/api/quiz.go | 369 ++++++++ backend/internal/api/rate_limit.go | 301 +++++++ backend/internal/api/rate_limit_test.go | 104 +++ backend/internal/api/recovery.go | 16 + backend/internal/api/recovery_test.go | 80 ++ backend/internal/api/register.go | 180 ++++ backend/internal/api/register_test.go | 307 +++++++ backend/internal/api/route_label_test.go | 25 + backend/internal/api/router.go | 154 ++++ backend/internal/api/scheduler.go | 127 +++ backend/internal/api/security_test.go | 246 ++++++ backend/internal/api/server.go | 228 +++++ backend/internal/api/session_cookies.go | 77 ++ backend/internal/api/settings.go | 178 ++++ backend/internal/api/tree.go | 283 +++++++ backend/internal/api/upload_sniff.go | 219 +++++ backend/internal/api/upload_sniff_test.go | 126 +++ backend/internal/api/util.go | 7 + backend/internal/api/validate.go | 228 +++++ backend/internal/api/validate_test.go | 88 ++ backend/internal/api/video_accept.go | 109 +++ backend/internal/api/video_accept_test.go | 75 ++ backend/internal/api/whitelist.go | 132 +++ backend/internal/api/whitelist_test.go | 321 +++++++ backend/internal/api/ws.go | 185 ++++ backend/internal/api/ws_handler.go | 80 ++ backend/internal/api/ws_test.go | 97 +++ 58 files changed, 11756 insertions(+) create mode 100644 backend/internal/api/admin.go create mode 100644 backend/internal/api/api_test.go create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/blacklist.go create mode 100644 backend/internal/api/blacklist_test.go create mode 100644 backend/internal/api/captcha.go create mode 100644 backend/internal/api/captcha_test.go create mode 100644 backend/internal/api/certificate.go create mode 100644 backend/internal/api/challenges.go create mode 100644 backend/internal/api/chat.go create mode 100644 backend/internal/api/chat_export.go create mode 100644 backend/internal/api/chat_export_test.go create mode 100644 backend/internal/api/community.go create mode 100644 backend/internal/api/community_test.go create mode 100644 backend/internal/api/errors.go create mode 100644 backend/internal/api/errors_test.go create mode 100644 backend/internal/api/events.go create mode 100644 backend/internal/api/events_test.go create mode 100644 backend/internal/api/flow_test.go create mode 100644 backend/internal/api/http_middleware.go create mode 100644 backend/internal/api/identity_test.go create mode 100644 backend/internal/api/invites.go create mode 100644 backend/internal/api/invites_test.go create mode 100644 backend/internal/api/leaderboard.go create mode 100644 backend/internal/api/learning.go create mode 100644 backend/internal/api/media_sign.go create mode 100644 backend/internal/api/media_sign_test.go create mode 100644 backend/internal/api/middleware.go create mode 100644 backend/internal/api/notifications.go create mode 100644 backend/internal/api/pagination.go create mode 100644 backend/internal/api/prod_hardening_test.go create mode 100644 backend/internal/api/quiz.go create mode 100644 backend/internal/api/rate_limit.go create mode 100644 backend/internal/api/rate_limit_test.go create mode 100644 backend/internal/api/recovery.go create mode 100644 backend/internal/api/recovery_test.go create mode 100644 backend/internal/api/register.go create mode 100644 backend/internal/api/register_test.go create mode 100644 backend/internal/api/route_label_test.go create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/api/scheduler.go create mode 100644 backend/internal/api/security_test.go create mode 100644 backend/internal/api/server.go create mode 100644 backend/internal/api/session_cookies.go create mode 100644 backend/internal/api/settings.go create mode 100644 backend/internal/api/tree.go create mode 100644 backend/internal/api/upload_sniff.go create mode 100644 backend/internal/api/upload_sniff_test.go create mode 100644 backend/internal/api/util.go create mode 100644 backend/internal/api/validate.go create mode 100644 backend/internal/api/validate_test.go create mode 100644 backend/internal/api/video_accept.go create mode 100644 backend/internal/api/video_accept_test.go create mode 100644 backend/internal/api/whitelist.go create mode 100644 backend/internal/api/whitelist_test.go create mode 100644 backend/internal/api/ws.go create mode 100644 backend/internal/api/ws_handler.go create mode 100644 backend/internal/api/ws_test.go diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go new file mode 100644 index 0000000..96210cd --- /dev/null +++ b/backend/internal/api/admin.go @@ -0,0 +1,890 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" +) + +func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { + stats, err := s.store.Stats(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری آمار ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"stats": stats}) +} + +func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + role := r.URL.Query().Get("role") + p := parsePageParams(r) + users, total, err := s.store.ListUsers(r.Context(), query, role, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد") + return + } + writePage(w, users, total, p) +} + +func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + Phone string `json:"phone"` + SecurityQuestion string `json:"securityQuestion"` + SecurityAnswer string `json:"securityAnswer"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Username = strings.ToLower(strings.TrimSpace(req.Username)) + name := strings.TrimSpace(req.Name) + phone := normalizePhone(req.Phone) + secQ := strings.TrimSpace(req.SecurityQuestion) + secA := strings.TrimSpace(req.SecurityAnswer) + + if msg := validatePersonName(name); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + if !validEmail(req.Email) { + writeErr(w, http.StatusBadRequest, msgEmailInvalid) + return + } + if !validUsername(req.Username) { + writeErr(w, http.StatusBadRequest, msgUsernameInvalid) + return + } + if !validPhone(phone) { + writeErr(w, http.StatusBadRequest, msgPhoneInvalid) + return + } + if msg := validatePasswordOnly(req.Password); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + if msg := validateSecurityQA(secQ, secA); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + var role models.Role + switch req.Role { + case "", "student": + role = models.RoleStudent + case "mentor": + role = models.RoleMentor + case "admin": + role = models.RoleAdmin + default: + writeErr(w, http.StatusBadRequest, "نقش نامعتبر است") + return + } + if actor := currentUser(r); actor.Role != models.RoleAdmin && role != models.RoleStudent { + writeErr(w, http.StatusForbidden, "منتور فقط می‌تواند حساب هنرجو بسازد") + return + } + if _, err := s.store.GetUserByEmail(r.Context(), req.Email); err == nil { + writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است") + return + } + if _, err := s.store.GetUserByUsername(r.Context(), req.Username); err == nil { + writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است") + return + } + if phone != "" { + if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil { + writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است") + return + } + } + hash, err := s.auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد") + return + } + answerHash, err := s.auth.HashPassword(strings.ToLower(secA)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد") + return + } + user, err := s.store.CreateUser(r.Context(), name, req.Email, req.Username, hash, phone, secQ, answerHash, role) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت کاربر ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"user": user}) +} + +func (s *Server) handleAdminLockUser(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if id == actor.ID { + writeErr(w, http.StatusBadRequest, "نمی‌توانید حساب خودتان را قفل کنید") + return + } + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + target, err := s.store.GetUserByID(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + if target.Role.IsStaff() { + writeErr(w, http.StatusBadRequest, "حساب ادمین و منتور قابل قفل نیست") + return + } + u, err := s.store.LockUser(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + observability.Activity("account.locked", "user_id", id, "actor_id", actor.ID, "reason", "staff") + go func() { + _ = s.notify.Notify(context.Background(), id, "progress", "account_locked", + "حساب توسط منتور محدود شد", "حساب شما توسط "+actor.Name+" محدود شد.", "/unwrap?tab=chats", nil) + }() + writeJSON(w, http.StatusOK, map[string]any{"user": u}) +} + +func (s *Server) handleAdminUnlockUser(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + u, err := s.store.UnlockUser(r.Context(), id, actor.ID) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + _ = s.store.ResolveUnlockRequests(r.Context(), id) + observability.Activity("account.unlocked", "user_id", id, "actor_id", actor.ID) + go func() { + _ = s.notify.Notify(context.Background(), id, "progress", "account_unlocked", + "محدودیت حساب برداشته شد", "محدودیت حساب شما توسط "+actor.Name+" برداشته شد. ۳ جان شما بازگردانده شد.", "/cap", nil) + }() + writeJSON(w, http.StatusOK, map[string]any{"user": u}) +} + +func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if id == actor.ID { + writeErr(w, http.StatusBadRequest, "نمی‌توانید نقش حساب خودتان را تغییر دهید") + return + } + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + var req struct { + Role string `json:"role"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + var role models.Role + switch req.Role { + case "student", "mentor", "admin": + role = models.Role(req.Role) + default: + writeErr(w, http.StatusBadRequest, "نقش نامعتبر است") + return + } + if actor.Role != models.RoleAdmin && role != models.RoleStudent { + writeErr(w, http.StatusForbidden, "منتور فقط می‌تواند نقش هنرجو را تنظیم کند") + return + } + if _, err := s.store.Pool().Exec(r.Context(), `UPDATE users SET role=$1 WHERE id=$2`, role.String(), id); err != nil { + writeErr(w, http.StatusInternalServerError, "تغییر نقش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + target, err := s.store.GetUserByID(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + var req struct { + Name *string `json:"name"` + Email *string `json:"email"` + Username *string `json:"username"` + Password *string `json:"password"` + Role *string `json:"role"` + Hearts *int `json:"hearts"` + XP *int `json:"xp"` + Phone *string `json:"phone"` + SecurityQuestion *string `json:"securityQuestion"` + SecurityAnswer *string `json:"securityAnswer"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + name := target.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + if msg := validatePersonName(name); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + } + email := target.Email + if req.Email != nil && strings.TrimSpace(*req.Email) != "" { + email = strings.ToLower(strings.TrimSpace(*req.Email)) + if !validEmail(email) { + writeErr(w, http.StatusBadRequest, msgEmailInvalid) + return + } + if email != target.Email { + if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil { + writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است") + return + } + } + } + username := target.Username + if req.Username != nil && strings.TrimSpace(*req.Username) != "" { + username = strings.ToLower(strings.TrimSpace(*req.Username)) + if !validUsername(username) { + writeErr(w, http.StatusBadRequest, msgUsernameInvalid) + return + } + if username != target.Username { + if _, err := s.store.GetUserByUsername(r.Context(), username); err == nil { + writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است") + return + } + } + } + phone := target.Phone + if req.Phone != nil { + phone = normalizePhone(*req.Phone) + if !validPhone(phone) { + writeErr(w, http.StatusBadRequest, msgPhoneInvalid) + return + } + if phone != "" && phone != target.Phone { + if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil { + writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است") + return + } + } + } + hash := "" + if req.Password != nil && *req.Password != "" { + writeErr(w, http.StatusBadRequest, "برای تغییر رمز از مسیر ریست با سوال امنیتی استفاده کنید") + return + } + secQ := "" + secAHash := "" + if req.SecurityQuestion != nil { + secQ = strings.TrimSpace(*req.SecurityQuestion) + if secQ != "" { + qn := utf8.RuneCountInString(secQ) + if qn < minSecQLen { + writeErr(w, http.StatusBadRequest, msgSecQShort) + return + } + if qn > maxSecQLen { + writeErr(w, http.StatusBadRequest, msgSecQLong) + return + } + } + } + if req.SecurityAnswer != nil && strings.TrimSpace(*req.SecurityAnswer) != "" { + if secQ == "" && target.SecurityQuestion == "" { + writeErr(w, http.StatusBadRequest, "برای تنظیم جواب، سوال امنیتی هم لازم است") + return + } + if secQ == "" { + secQ = target.SecurityQuestion + } + ans := strings.TrimSpace(*req.SecurityAnswer) + an := utf8.RuneCountInString(ans) + if an < minSecALen { + writeErr(w, http.StatusBadRequest, msgSecAShort) + return + } + if an > maxSecALen { + writeErr(w, http.StatusBadRequest, msgSecALong) + return + } + var err error + secAHash, err = s.auth.HashPassword(strings.ToLower(ans)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد") + return + } + } + role := target.Role + if req.Role != nil { + switch *req.Role { + case "student", "mentor", "admin": + newRole := models.Role(*req.Role) + if id == actor.ID && newRole != target.Role { + writeErr(w, http.StatusBadRequest, "نمی‌توانید نقش حساب خودتان را تغییر دهید") + return + } + if actor.Role != models.RoleAdmin && newRole != models.RoleStudent { + writeErr(w, http.StatusForbidden, "منتور فقط می‌تواند نقش هنرجو را تنظیم کند") + return + } + role = newRole + default: + writeErr(w, http.StatusBadRequest, "نقش نامعتبر است") + return + } + } + hearts, xp := target.Hearts, target.XP + if id == actor.ID { + // Self-service via admin panel must not mint XP/hearts. + req.Hearts = nil + req.XP = nil + } + if req.Hearts != nil { + hearts = *req.Hearts + } + if req.XP != nil { + xp = *req.XP + } + user, err := s.store.UpdateUser(r.Context(), id, name, email, username, hash, phone, secQ, secAHash, role, hearts, xp) + if err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی کاربر ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + target, err := s.store.GetUserByID(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + var req struct { + SecurityAnswer string `json:"securityAnswer"` + NewPassword string `json:"newPassword"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if target.SecurityAnswerHash == "" { + writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است") + return + } + if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) { + writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است") + return + } + if msg := validatePasswordOnly(req.NewPassword); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + hash, err := s.auth.HashPassword(req.NewPassword) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد") + return + } + if err := s.store.SetUserPassword(r.Context(), id, hash); err != nil { + writeErr(w, http.StatusInternalServerError, "ریست گذرواژه ممکن نشد") + return + } + observability.Activity("account.password_reset", "user_id", id, "actor_id", actor.ID) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + id := routeID(r, "id") + if id == actor.ID { + writeErr(w, http.StatusBadRequest, "نمی‌توانید حساب خودتان را حذف کنید") + return + } + if err := s.canActOn(actor, id); err != nil { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + if err := s.store.DeleteUser(r.Context(), id); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// canActOn verifies a non-admin actor may only mutate student accounts. +func (s *Server) canActOn(actor *models.User, targetID int64) error { + if actor.Role == models.RoleAdmin { + return nil + } + target, err := s.store.GetUserByID(context.Background(), targetID) + if err != nil { + return err + } + if target.Role != models.RoleStudent { + return fmt.Errorf("فقط می‌توانید روی حساب هنرجویان عمل کنید") + } + return nil +} + +// ---- chapters ---- + +func (s *Server) handleAdminListChapters(w http.ResponseWriter, r *http.Request) { + chapters, err := s.store.ListChapters(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فصل‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"chapters": chapters}) +} + +func (s *Server) handleAdminCreateChapter(w http.ResponseWriter, r *http.Request) { + var c models.Chapter + if err := bodyJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + id, err := s.store.CreateChapter(r.Context(), &c) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت فصل ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"chapter": c, "id": id}) +} + +func (s *Server) handleAdminUpdateChapter(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var c models.Chapter + if err := bodyJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.UpdateChapter(r.Context(), id, &c); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی فصل ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteChapter(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if err := s.store.DeleteChapter(r.Context(), id); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف فصل ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// ---- lessons ---- + +func (s *Server) handleAdminListLessons(w http.ResponseWriter, r *http.Request) { + lessons, err := s.store.ListLessons(r.Context(), true) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری درس‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"lessons": lessons}) +} + +func (s *Server) handleAdminCreateLesson(w http.ResponseWriter, r *http.Request) { + var l models.Lesson + if err := bodyJSON(r, &l); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if l.DurationSeconds <= 0 { + writeErr(w, http.StatusBadRequest, "مدت درس الزامی است") + return + } + id, err := s.store.CreateLesson(r.Context(), &l) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت درس ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"lesson": l, "id": id}) +} + +func (s *Server) handleAdminUpdateLesson(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var l models.Lesson + if err := bodyJSON(r, &l); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.UpdateLesson(r.Context(), id, &l); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی درس ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if err := s.store.DeleteLesson(r.Context(), id); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف درس ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// ---- questions ---- + +func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) { + lessonID := routeID(r, "id") + questions, err := s.store.ListQuestions(r.Context(), lessonID, true) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پرسش‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"questions": questions}) +} + +func (s *Server) handleAdminCreateQuestion(w http.ResponseWriter, r *http.Request) { + lessonID := routeID(r, "id") + var q models.MCQQuestion + if err := bodyJSON(r, &q); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + q.LessonID = lessonID + if len(q.Options) < 2 { + writeErr(w, http.StatusBadRequest, "دست‌کم ۲ گزینه لازم است") + return + } + if q.AnswerIndex < 0 || q.AnswerIndex >= len(q.Options) { + writeErr(w, http.StatusBadRequest, "شاخص پاسخ نامعتبر است") + return + } + id, err := s.store.CreateQuestion(r.Context(), &q) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت پرسش ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"question": q, "id": id}) +} + +func (s *Server) handleAdminUpdateQuestion(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var q models.MCQQuestion + if err := bodyJSON(r, &q); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if len(q.Options) < 2 { + writeErr(w, http.StatusBadRequest, "دست‌کم ۲ گزینه لازم است") + return + } + if q.AnswerIndex < 0 || q.AnswerIndex >= len(q.Options) { + writeErr(w, http.StatusBadRequest, "شاخص پاسخ نامعتبر است") + return + } + if err := s.store.UpdateQuestion(r.Context(), id, &q); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی پرسش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteQuestion(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if err := s.store.DeleteQuestion(r.Context(), id); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف پرسش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// ---- video library ---- + +func (s *Server) handleAdminListVideos(w http.ResponseWriter, r *http.Request) { + rows, err := s.store.ListVideoAssets(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری ویدیوها ممکن نشد") + return + } + videos := make([]map[string]any, 0, len(rows)) + for _, v := range rows { + videos = append(videos, map[string]any{ + "key": v.Key, + "mime": v.Mime, + "size": v.SizeBytes, + "lastModified": v.CreatedAt, + }) + } + // Also surface keys that exist only on lessons (legacy uploads). + lessons, _ := s.store.ListLessons(r.Context(), true) + seen := map[string]struct{}{} + for _, v := range videos { + if k, ok := v["key"].(string); ok { + seen[k] = struct{}{} + } + } + for _, l := range lessons { + if l.VideoKey == "" { + continue + } + if _, ok := seen[l.VideoKey]; ok { + continue + } + seen[l.VideoKey] = struct{}{} + videos = append(videos, map[string]any{ + "key": l.VideoKey, + "mime": "video/mp4", + "size": 0, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"videos": videos}) +} + +func (s *Server) handleAdminDeleteVideo(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(r.URL.Query().Get("key")) + if key == "" { + writeErr(w, http.StatusBadRequest, "کلید ویدیو الزامی است") + return + } + key = filepath.Base(key) + if err := s.store.DeleteVideoAsset(r.Context(), key); err != nil { + writeErr(w, http.StatusInternalServerError, "حذف ویدیو ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminUploadVideo(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxAdminVideoBytes+1<<20) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم ویدیو باید کمتر از ۲۰۰ مگابایت باشد") + return + } + file, header, err := r.FormFile("file") + if err != nil { + writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است") + return + } + defer file.Close() + + accepted, err := acceptAdminVideo(header, file) + if err != nil { + if strings.Contains(err.Error(), "۲۰۰ مگابایت") { + writeErrCode(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, err.Error()) + return + } + writeValidationErr(w, err.Error()) + return + } + + // Prefer stable mp4-first naming; keep real extension for playback. + key := fmt.Sprintf("lesson-%d%s", time.Now().UnixNano(), accepted.Ext) + key = filepath.Base(key) + url, err := s.storage.Save(key, file, header.Size) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ ویدیو ممکن نشد") + return + } + if err := s.store.CreateVideoAsset(r.Context(), key, accepted.Mime, header.Size); err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت ویدیو در کتابخانه ممکن نشد") + return + } + observability.VideoUploads.Inc() + if actor := currentUser(r); actor != nil { + observability.Activity("video.upload", "user_id", actor.ID, "key", key, "size", header.Size, "mime", accepted.Mime) + } + writeJSON(w, http.StatusCreated, map[string]any{ + "url": s.signExistingMediaURL(url), + "key": key, + "size": header.Size, + "mime": accepted.Mime, + }) +} + +// ---- events (admin) ---- + +func (s *Server) handleAdminListEvents(w http.ResponseWriter, r *http.Request) { + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT id, title, description, event_type, external_url, starts_at, ends_at, is_active, created_at + FROM events ORDER BY starts_at DESC LIMIT 200`) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری رویدادها ممکن نشد") + return + } + var events []models.Event + for rows.Next() { + var e models.Event + if err := rows.Scan(&e.ID, &e.Title, &e.Description, &e.EventType, &e.ExternalURL, &e.StartsAt, &e.EndsAt, &e.IsActive, &e.CreatedAt); err != nil { + rows.Close() + writeErr(w, http.StatusInternalServerError, "بارگذاری رویدادها ممکن نشد") + return + } + events = append(events, e) + } + rows.Close() + if events == nil { + events = []models.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} + +func (s *Server) handleAdminCreateEvent(w http.ResponseWriter, r *http.Request) { + admin := currentUser(r) + var e models.Event + if err := bodyJSON(r, &e); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if msg := validateAdminEvent(&e, true); msg != "" { + writeValidationErr(w, msg) + return + } + e.CreatedBy = &admin.ID + id, err := s.store.CreateEvent(r.Context(), &e) + if err != nil { + writeInternalErr(w, "admin.events.create", err, "ساخت رویداد ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"event": e, "id": id}) +} + +func (s *Server) handleAdminUpdateEvent(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var e models.Event + if err := bodyJSON(r, &e); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if msg := validateAdminEvent(&e, false); msg != "" { + writeValidationErr(w, msg) + return + } + if err := s.store.UpdateEvent(r.Context(), id, &e); err != nil { + writeInternalErr(w, "admin.events.update", err, "به‌روزرسانی رویداد ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteEvent(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if err := s.store.DeleteEvent(r.Context(), id); err != nil { + writeInternalErr(w, "admin.events.delete", err, "حذف رویداد ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// validateAdminEvent normalizes and checks event fields. create=true defaults isActive to true when unset via zero-value ambiguity is handled by callers sending explicit bool. +func validateAdminEvent(e *models.Event, create bool) string { + e.Title = strings.TrimSpace(e.Title) + e.Description = strings.TrimSpace(e.Description) + e.ExternalURL = strings.TrimSpace(e.ExternalURL) + e.EventType = strings.TrimSpace(e.EventType) + if e.EventType == "" { + e.EventType = "meet" + } + switch e.EventType { + case "meet", "zoom", "workshop": + default: + return "نوع رویداد نامعتبر است" + } + if e.Title == "" || e.StartsAt.IsZero() || e.EndsAt.IsZero() { + return "عنوان، زمان شروع و زمان پایان الزامی هستند" + } + if utf8.RuneCountInString(e.Title) > 200 { + return "عنوان رویداد خیلی طولانی است" + } + if utf8.RuneCountInString(e.Description) > 5000 { + return "توضیح رویداد خیلی طولانی است" + } + if e.EndsAt.Before(e.StartsAt) { + return "زمان پایان باید پس از زمان شروع باشد" + } + _ = create + return "" +} + +// ---- announcements ---- + +func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) { + var req struct { + Title string `json:"title"` + Body string `json:"body"` + Route string `json:"route"` + Category string `json:"category"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + req.Title = strings.TrimSpace(req.Title) + req.Body = strings.TrimSpace(req.Body) + req.Route = strings.TrimSpace(req.Route) + if utf8.RuneCountInString(req.Title) < 3 { + writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی کوتاه است") + return + } + if utf8.RuneCountInString(req.Title) > 200 { + writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی طولانی است") + return + } + if utf8.RuneCountInString(req.Body) < 8 { + writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی کوتاه است") + return + } + if utf8.RuneCountInString(req.Body) > 500 { + writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی طولانی است") + return + } + if req.Category == "" { + req.Category = "event" + } + switch req.Category { + case "event", "progress", "gamification", "mentor": + default: + writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است") + return + } + userIDs, err := s.store.AllActiveUserIDs(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد") + return + } + go func() { + for _, uid := range userIDs { + _ = s.notify.Notify(context.Background(), uid, req.Category, "announcement", + req.Title, req.Body, req.Route, nil) + } + }() + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recipients": len(userIDs)}) +} diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..e461179 --- /dev/null +++ b/backend/internal/api/api_test.go @@ -0,0 +1,295 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "io/fs" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "pargar/backend/internal/api" + "pargar/backend/internal/config" + "pargar/backend/internal/db" + "pargar/backend/internal/redisdb" + "pargar/backend/internal/seed" + "pargar/backend/internal/storage" + "pargar/backend/internal/store" +) + +var ( + adminURL = testDatabaseURL("postgres") + testURL = testDatabaseURL("pargar_test") + redisURL = testRedisURL() + integrationOK bool +) + +func testRedisURL() string { + if v := os.Getenv("REDIS_URL"); v != "" { + return v + } + return "redis://localhost:6379" +} + +func testDatabaseURL(database string) string { + user := os.Getenv("POSTGRES_USER") + if user == "" { + user = "pargar" + } + password := os.Getenv("POSTGRES_PASSWORD") + if password == "" { + password = "pargar" + } + return "postgres://" + user + ":" + password + "@localhost:5432/" + database + "?sslmode=disable" +} + +type testEnv struct { + server *httptest.Server + pool *pgxpool.Pool + rdb *redis.Client +} + +func TestMain(m *testing.M) { + _ = os.Setenv("ADMIN_PASSWORD", "password123") + // Local integration tests need captcha plaintext; production never sets this. + _ = os.Setenv("CAPTCHA_EXPOSE_ANSWER", "true") + _ = os.Setenv("DEV_MODE", "true") + ctx := context.Background() + root, err := pgxpool.New(ctx, adminURL) + if err != nil { + log.Printf("api integration tests disabled: %v", err) + os.Exit(m.Run()) + return + } + _, _ = root.Exec(ctx, `DROP DATABASE IF EXISTS pargar_test`) + if _, err := root.Exec(ctx, `CREATE DATABASE pargar_test`); err != nil { + root.Close() + log.Printf("api integration tests disabled: %v", err) + os.Exit(m.Run()) + return + } + root.Close() + integrationOK = true + os.Exit(m.Run()) +} + +func setup(t *testing.T) *testEnv { + t.Helper() + if !integrationOK { + t.Skip("postgres unavailable") + } + ctx := context.Background() + pool, err := db.Connect(ctx, testURL) + if err != nil { + t.Fatalf("connect: %v", err) + } + sub, _ := fs.Sub(db.MigrationFiles, "migrations") + if err := db.Migrate(ctx, pool, sub); err != nil { + t.Fatalf("migrate: %v", err) + } + rdb, err := redisdb.Connect(ctx, redisURL) + if err != nil { + t.Fatalf("redis: %v", err) + } + if err := rdb.RDB().FlushDB(ctx).Err(); err != nil { + t.Fatalf("flush redis: %v", err) + } + st := store.New(pool) + if err := seed.Run(ctx, st, true); err != nil { + t.Fatalf("seed: %v", err) + } + cfg := config.Load() + srv := api.NewServer(cfg, st, rdb.RDB(), storage.NewLocal(t.TempDir(), "http://localhost:8080")) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { + ts.Close() + rdb.RDB().Close() + _, _ = pool.Exec(ctx, `TRUNCATE users, lessons, chapters, mcq_questions, watch_heartbeats, + watch_sessions, lesson_progress, quiz_attempts, xp_events, weekly_leaderboard, events, + event_rsvps, challenges, challenge_progress, chat_messages, notifications, notification_prefs, video_assets, + refresh_tokens, reminders_sent, certificates, certificate_physical_orders, + unlock_requests, registration_ips, registration_invites, registration_phone_whitelist, + registration_phone_blacklist, registration_blacklist_attempts, chat_export_tokens + RESTART IDENTITY CASCADE`) + _, _ = pool.Exec(ctx, `UPDATE app_settings SET value='false' WHERE key='registration_enabled'`) + pool.Close() + }) + return &testEnv{server: ts, pool: pool, rdb: rdb.RDB()} +} + +func (e *testEnv) do(t *testing.T, method, path, token string, body any) (int, map[string]any) { + t.Helper() + return e.doHeaders(t, method, path, token, nil, body) +} + +func (e *testEnv) doHeaders(t *testing.T, method, path, token string, headers map[string]string, body any) (int, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req, _ := http.NewRequest(method, e.server.URL+path, &buf) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +const bootstrapAdminEmail = "admin@example.com" + +// register creates a fresh student via the admin-only endpoint, then logs in. +func register(t *testing.T, e *testEnv, name, email string) (string, int64) { + t.Helper() + admin := login(t, e, bootstrapAdminEmail) + username := strings.Split(email, "@")[0] + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": name, "email": email, "username": username, "password": "password123", "role": "student", + "phone": "", "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("admin create user: %d %v", code, body) + } + uid := int64(body["user"].(map[string]any)["id"].(float64)) + token := login(t, e, email) + return token, uid +} + +func fetchCaptcha(t *testing.T, e *testEnv) (challengeID, answer string) { + t.Helper() + code, body := e.do(t, "GET", "/api/auth/captcha", "", nil) + if code != http.StatusOK { + t.Fatalf("captcha: %d %v", code, body) + } + id, _ := body["challengeId"].(string) + img, _ := body["imageBase64"].(string) + answer, _ = body["answer"].(string) + if id == "" || img == "" || answer == "" { + t.Fatalf("captcha missing fields: %v", body) + } + return id, answer +} + +func loginBody(t *testing.T, e *testEnv, identity, password string) map[string]any { + t.Helper() + id, answer := fetchCaptcha(t, e) + body := map[string]any{ + "password": password, + "challengeId": id, + "captchaAnswer": answer, + } + if strings.Contains(identity, "@") { + body["email"] = identity + } else { + body["username"] = identity + } + return body +} + +func login(t *testing.T, e *testEnv, email string) string { + t.Helper() + code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, email, "password123")) + if code != http.StatusOK { + t.Fatalf("login %s: %d %v", email, code, body) + } + return body["accessToken"].(string) +} + +func TestAuthRateLimit(t *testing.T) { + e := setup(t) + hitLimit := false + for i := 0; i < 40; i++ { + code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong")) + if code == http.StatusTooManyRequests { + if body["error"] != "تعداد تلاش‌ها بیش از حد مجاز است؛ کمی بعد دوباره تلاش کنید" { + t.Fatalf("unexpected rate limit message: %v", body) + } + hitLimit = true + break + } + if code != http.StatusUnauthorized { + t.Fatalf("request %d expected unauthorized or rate limit, got %d %v", i+1, code, body) + } + } + if !hitLimit { + t.Fatal("expected adaptive rate limit after repeated failed logins") + } +} + +// watchToUnlock pumps heartbeats until the quiz unlocks. +// Wall-clock anti-cheat caps delta by real elapsed time, so each pulse first +// rewinds the session heartbeat timestamp (tests only). +func watchToUnlock(t *testing.T, e *testEnv, token string, lesson int64) { + t.Helper() + for i := 1; i <= 6; i++ { + rewindWatchSession(t, e, token, lesson) + code, body := e.do(t, "POST", "/api/lessons/"+itoa(lesson)+"/heartbeat", token, + map[string]any{"position": float64(i * 9), "delta": 9, "seq": i}) + if code != http.StatusOK { + t.Fatalf("heartbeat: %d %v", code, body) + } + if body["quizUnlocked"] == true { + return + } + } +} + +func rewindWatchSession(t *testing.T, e *testEnv, token string, lesson int64) { + t.Helper() + code, me := e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("auth/me for rewind: %d %v", code, me) + } + u, _ := me["user"].(map[string]any) + id, _ := u["id"].(float64) + if id < 1 { + t.Fatalf("auth/me missing user id: %v", me) + } + _, err := e.pool.Exec(context.Background(), ` + INSERT INTO watch_sessions (user_id, lesson_id, started_at, last_heartbeat_at, last_position) + VALUES ($1, $2, now() - interval '30 seconds', now() - interval '30 seconds', 0) + ON CONFLICT (user_id, lesson_id) DO UPDATE + SET last_heartbeat_at = now() - interval '30 seconds'`, int64(id), lesson) + if err != nil { + t.Fatalf("rewind session: %v", err) + } +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..c96fe40 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,342 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "pargar/backend/internal/config" + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +type AuthService struct { + cfg *config.Config + store *store.Store +} + +type Claims struct { + UserID int64 `json:"uid"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func NewAuthService(cfg *config.Config, st *store.Store) *AuthService { + return &AuthService{cfg: cfg, store: st} +} + +func (a *AuthService) SignAccess(user *models.User) (string, error) { + claims := Claims{ + UserID: user.ID, + Role: user.Role.String(), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(user.ID, 10), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.cfg.AccessTokenTTL)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(a.cfg.JWTSecret)) +} + +func (a *AuthService) SignRefresh() (string, string, error) { + raw := make([]byte, 48) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + token := hex.EncodeToString(raw) + hash := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(hash[:]), nil +} + +func (a *AuthService) refreshTTL(rememberMe bool) time.Duration { + if rememberMe { + return a.cfg.RefreshTokenTTL + } + return a.cfg.RefreshTokenTTLSession +} + +func (a *AuthService) IssueForUser(ctx context.Context, user *models.User) (access, refresh string, err error) { + return a.IssueForUserTTL(ctx, user, a.cfg.RefreshTokenTTL) +} + +func (a *AuthService) IssueForUserTTL(ctx context.Context, user *models.User, refreshTTL time.Duration) (access, refresh string, err error) { + if refreshTTL <= 0 { + refreshTTL = a.cfg.RefreshTokenTTLSession + } + access, err = a.SignAccess(user) + if err != nil { + return "", "", err + } + rt, hash, err := a.SignRefresh() + if err != nil { + return "", "", err + } + if err := a.store.CreateRefreshToken(ctx, user.ID, hash, time.Now().Add(refreshTTL)); err != nil { + return "", "", err + } + return access, rt, nil +} + +// Authenticate resolves a user from the Authorization header. +func (s *Server) Authenticate(r *http.Request) (*models.User, error) { + tokenStr := accessTokenFromRequest(r) + if tokenStr == "" { + return nil, errors.New("توکن ورود ارسال نشده است") + } + user, err := s.auth.parseAndLoad(r.Context(), tokenStr) + if err != nil { + return nil, errors.New("توکن نامعتبر یا منقضی شده است") + } + return user, nil +} + +func (a *AuthService) parseAndLoad(ctx context.Context, tokenStr string) (*models.User, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("روش امضای توکن نامعتبر است") + } + return []byte(a.cfg.JWTSecret), nil + }) + if err != nil || !token.Valid { + return nil, errors.New("توکن نامعتبر است") + } + user, err := a.store.GetUserByID(ctx, claims.UserID) + if err != nil { + return nil, errors.New("توکن نامعتبر یا منقضی شده است") + } + if !user.IsActive { + return nil, errors.New("حساب غیرفعال است") + } + return user, nil +} + +func (a *AuthService) HashPassword(pw string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + return string(b), err +} + +func (a *AuthService) CheckPassword(hash, pw string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil +} + +// ---- handlers ---- + +type credsRequest struct { + Name string `json:"name"` + Email string `json:"email"` // legacy alias + Username string `json:"username"` // preferred login identifier + Password string `json:"password"` + ChallengeID string `json:"challengeId"` + CaptchaAnswer string `json:"captchaAnswer"` + RememberMe bool `json:"rememberMe"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req credsRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + ip := clientAddress(r) + if err := s.consumeCaptcha(r.Context(), req.ChallengeID, req.CaptchaAnswer); err != nil { + s.recordAuthFailure(r.Context(), "login", ip) + observability.Logins.WithLabelValues("captcha_fail").Inc() + observability.Activity("auth.captcha_failed", "remote", ip) + code, msg := captchaErrorMessage(err) + writeErr(w, code, msg) + return + } + login := strings.TrimSpace(req.Username) + if login == "" { + login = strings.TrimSpace(req.Email) + } + user, err := s.store.GetUserByLogin(r.Context(), login) + if err != nil || !s.auth.CheckPassword(user.PasswordHash, req.Password) { + s.recordAuthFailure(r.Context(), "login", ip) + observability.Logins.WithLabelValues("fail").Inc() + observability.Activity("auth.login_failed", "login", login, "remote", ip) + writeErr(w, http.StatusUnauthorized, "شناسه کاربری یا گذرواژه نادرست است") + return + } + if !user.IsActive { + observability.Logins.WithLabelValues("inactive").Inc() + writeErr(w, http.StatusForbidden, "حساب غیرفعال است") + return + } + refreshTTL := s.auth.refreshTTL(req.RememberMe) + access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد") + return + } + if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil { + meta.UserID = user.ID + } + s.clearAuthFailures(r.Context(), "login", ip) + observability.Logins.WithLabelValues("ok").Inc() + observability.Activity("auth.login", "user_id", user.ID, "role", user.Role.String()) + s.signUserMedia(user) + s.setSessionCookies(w, access, refresh, refreshTTL) + writeJSON(w, http.StatusOK, map[string]any{ + "accessToken": access, "refreshToken": refresh, "user": user, + }) +} + +func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refreshToken"` + } + _ = bodyJSON(r, &req) + refreshTok := refreshTokenFromRequest(r, req.RefreshToken) + if refreshTok == "" { + writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است") + return + } + hash := sha256.Sum256([]byte(refreshTok)) + userID, lifetime, ok, err := s.store.ConsumeRefreshToken(r.Context(), hex.EncodeToString(hash[:])) + if err != nil || !ok { + s.clearSessionCookies(w) + writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است") + return + } + user, err := s.store.GetUserByID(r.Context(), userID) + if err != nil { + s.clearSessionCookies(w) + writeErr(w, http.StatusUnauthorized, "توکن نوسازی نامعتبر است") + return + } + if !user.IsActive { + s.clearSessionCookies(w) + writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است") + return + } + // Preserve remember-me vs short session when rotating the refresh token. + ttl := s.cfg.RefreshTokenTTLSession + if lifetime >= 48*time.Hour { + ttl = s.cfg.RefreshTokenTTL + } + access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, ttl) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد") + return + } + s.signUserMedia(user) + s.setSessionCookies(w, access, refresh, ttl) + writeJSON(w, http.StatusOK, map[string]any{ + "accessToken": access, "refreshToken": refresh, "user": user, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + var req struct { + RefreshToken string `json:"refreshToken"` + } + _ = bodyJSON(r, &req) + refreshTok := refreshTokenFromRequest(r, req.RefreshToken) + if refreshTok != "" { + hash := sha256.Sum256([]byte(refreshTok)) + _ = s.store.RevokeRefreshToken(r.Context(), hex.EncodeToString(hash[:])) + } else if u != nil { + _ = s.store.RevokeAllUserRefreshTokens(r.Context(), u.ID) + } + s.clearSessionCookies(w) + if u != nil { + observability.Activity("auth.logout", "user_id", u.ID) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hearts, _ := s.hearts.RefreshHearts(r.Context(), u) + user, err := s.store.GetUserByID(r.Context(), hearts.ID) + if err != nil { + user = hearts + } + s.signUserMedia(user) + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +var avatarVariants = map[string]bool{ + "marble": true, "beam": true, "pixel": true, "sunset": true, "ring": true, "bauhaus": true, +} + +func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + var req struct { + Variant *string `json:"avatarVariant"` + Palette *string `json:"avatarPalette"` + Photo *string `json:"avatarPhoto"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if req.Variant != nil && !avatarVariants[*req.Variant] { + writeErr(w, http.StatusBadRequest, "گونهٔ آواتار نامعتبر است") + return + } + if req.Photo != nil && *req.Photo != "" { + key := mediaKeyFromURL(*req.Photo) + if !mediaKeyOwnedByUser(key, u.ID) { + writeErr(w, http.StatusBadRequest, "نشانی تصویر آواتار نامعتبر است") + return + } + // Persist without signature query. + if q := strings.Index(*req.Photo, "?"); q >= 0 { + stripped := (*req.Photo)[:q] + req.Photo = &stripped + } + } + user, err := s.store.UpdateAvatar(r.Context(), u.ID, req.Variant, req.Palette, req.Photo) + if err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی آواتار ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func (s *Server) handleAvatarPhotoUpload(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + const maxAvatarSize = 6 << 20 // 6 MB + r.Body = http.MaxBytesReader(w, r.Body, maxAvatarSize+1<<20) + if err := r.ParseMultipartForm(maxAvatarSize + 1<<20); err != nil { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است") + return + } + file, h, err := r.FormFile("file") + if err != nil { + writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است") + return + } + defer file.Close() + if h.Size > maxAvatarSize { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد") + return + } + accepted, err := acceptAvatarImage(h, file) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + key := fmt.Sprintf("avatars/%d-%d%s", u.ID, time.Now().UnixNano(), accepted.Ext) + rawURL, err := s.storage.Save(key, file, h.Size) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)}) +} diff --git a/backend/internal/api/blacklist.go b/backend/internal/api/blacklist.go new file mode 100644 index 0000000..c5dc5c7 --- /dev/null +++ b/backend/internal/api/blacklist.go @@ -0,0 +1,155 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +type importBlacklistRequest struct { + Phones []string `json:"phones"` + Text string `json:"text"` + ResolveConflicts bool `json:"resolveConflicts"` + Force bool `json:"force"` +} + +func (s *Server) handleAdminImportBlacklist(w http.ResponseWriter, r *http.Request) { + var req importBlacklistRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + phones, invalid := parseWhitelistPhones(importWhitelistRequest{ + Phones: req.Phones, + Text: req.Text, + }) + if len(phones) == 0 { + writeErr(w, http.StatusBadRequest, "هیچ شمارهٔ معتبری ارسال نشده است") + return + } + if len(phones) > 5000 { + writeErr(w, http.StatusBadRequest, "حداکثر ۵۰۰۰ شماره در هر واردات مجاز است") + return + } + admin := currentUser(r) + resolve := req.ResolveConflicts || req.Force + result, err := s.store.ImportBlacklistPhones(r.Context(), phones, admin.ID, resolve) + if err != nil { + writeErr(w, http.StatusInternalServerError, "واردات فهرست سیاه ممکن نشد") + return + } + wlConflicts := result.WhitelistedConflicts + if wlConflicts == nil { + wlConflicts = []string{} + } + rejectedPhones := result.RejectedPhones + if rejectedPhones == nil { + rejectedPhones = []string{} + } + observability.Activity("admin.blacklist_import", + "inserted", result.Inserted, "skipped", result.Skipped, + "removed_whitelist", result.RemovedWhitelist, "rejected", result.Rejected, + "whitelisted_conflicts", len(wlConflicts), + "invalid", invalid, "resolve", resolve, "admin_id", admin.ID) + writeJSON(w, http.StatusOK, map[string]any{ + "inserted": result.Inserted, + "skipped": result.Skipped, + "removedWhitelist": result.RemovedWhitelist, + "rejected": result.Rejected, + "rejectedPhones": rejectedPhones, + "whitelistedConflicts": wlConflicts, + "invalid": invalid, + "total": len(phones), + }) +} + +func (s *Server) handleAdminListBlacklist(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if digits := normalizePhone(q); digits != "" { + q = digits + } + p := parsePageParams(r) + entries, total, err := s.store.ListBlacklistPhones(r.Context(), q, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست سیاه ممکن نشد") + return + } + writePage(w, entries, total, p) +} + +func (s *Server) handleAdminDeleteBlacklist(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + if err := s.store.DeleteBlacklistPhone(r.Context(), id); err != nil { + if errors.Is(err, store.ErrBlacklistNotFound) { + writeErr(w, http.StatusNotFound, "شماره در فهرست سیاه یافت نشد") + return + } + writeErr(w, http.StatusInternalServerError, "حذف شماره ممکن نشد") + return + } + observability.Activity("admin.blacklist_delete", "id", id, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminListBlacklistAttempts(w http.ResponseWriter, r *http.Request) { + p := parsePageParams(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + // Legacy alias; prefer q going forward. + q = strings.TrimSpace(r.URL.Query().Get("phone")) + if digits := normalizePhone(q); digits != "" { + q = digits + } + } + entries, total, err := s.store.ListBlacklistAttempts(r.Context(), q, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری تاریخچه تلاش ممکن نشد") + return + } + writePage(w, entries, total, p) +} + +// rejectIfBlacklisted returns true if the response was already written. +// When blocked, an audit row is recorded (best-effort) before rejecting. +func (s *Server) rejectIfBlacklisted( + w http.ResponseWriter, + r *http.Request, + phone string, + path string, + inviteID *int64, + username string, + email string, +) bool { + blocked, err := s.store.IsPhoneBlacklisted(r.Context(), phone) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بررسی فهرست سیاه ممکن نشد") + return true + } + if !blocked { + return false + } + ip := clientAddress(r) + ua := strings.TrimSpace(r.UserAgent()) + if len(ua) > 500 { + ua = ua[:500] + } + if _, recErr := s.store.RecordBlacklistAttempt( + r.Context(), phone, path, inviteID, ip, ua, username, email, + ); recErr != nil { + observability.Activity("auth.blacklist_attempt_log_failed", + "phone", phone, "path", path, "error", recErr.Error()) + } else { + observability.Activity("auth.blacklist_attempt", + "phone", phone, "path", path, "remote", ip) + } + writeErr(w, http.StatusForbidden, "این شماره مجاز به ثبت‌نام نیست") + return true +} diff --git a/backend/internal/api/blacklist_test.go b/backend/internal/api/blacklist_test.go new file mode 100644 index 0000000..5612417 --- /dev/null +++ b/backend/internal/api/blacklist_test.go @@ -0,0 +1,349 @@ +package api_test + +import ( + "fmt" + "net/http" + "testing" +) + +func blacklistPhones(t *testing.T, e *testEnv, phones ...string) { + t.Helper() + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": phones, + "resolveConflicts": true, + }) + if code != http.StatusOK { + t.Fatalf("blacklist import: %d %v", code, body) + } +} + +func TestBlacklistImportListDelete(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "text": "09125550001\n09125550002,09125550001\n12345", + }) + if code != http.StatusOK { + t.Fatalf("import: %d %v", code, body) + } + if body["inserted"] != float64(2) { + t.Fatalf("inserted: %v", body["inserted"]) + } + if body["invalid"] != float64(1) { + t.Fatalf("invalid: %v", body["invalid"]) + } + + code, body = e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09125550001", "09125550003"}, + }) + if code != http.StatusOK { + t.Fatalf("reimport: %d %v", code, body) + } + if body["inserted"] != float64(1) || body["skipped"] != float64(1) { + t.Fatalf("expected insert1 skip1, got %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist", admin, nil) + if code != http.StatusOK { + t.Fatalf("list: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) != 3 { + t.Fatalf("expected 3, got %d", len(entries)) + } + id := int64(entries[0].(map[string]any)["id"].(float64)) + + code, body = e.do(t, "DELETE", "/api/admin/phone-blacklist/"+itoa(id), admin, nil) + if code != http.StatusOK { + t.Fatalf("delete: %d %v", code, body) + } + code, body = e.do(t, "GET", "/api/admin/phone-blacklist", admin, nil) + if code != http.StatusOK { + t.Fatalf("list after delete: %d %v", code, body) + } + if len(body["items"].([]any)) != 2 { + t.Fatalf("expected 2 after delete, got %v", body) + } +} + +func TestBlacklistImportBlockedWhenWhitelistedUntilForce(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + whitelistPhones(t, e, "09125550010") + + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09125550010"}, + }) + if code != http.StatusOK { + t.Fatalf("blacklist import: %d %v", code, body) + } + if body["inserted"] != float64(0) { + t.Fatalf("inserted without resolve: %v", body["inserted"]) + } + conflicts, ok := body["whitelistedConflicts"].([]any) + if !ok || len(conflicts) != 1 || conflicts[0] != "09125550010" { + t.Fatalf("whitelistedConflicts: %v", body["whitelistedConflicts"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("whitelist list: %d %v", code, body) + } + if len(body["items"].([]any)) != 1 { + t.Fatalf("expected whitelist intact, got %v", body) + } + + code, body = e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09125550010"}, + "resolveConflicts": true, + }) + if code != http.StatusOK { + t.Fatalf("force import: %d %v", code, body) + } + if body["inserted"] != float64(1) { + t.Fatalf("inserted: %v", body["inserted"]) + } + if body["removedWhitelist"] != float64(1) { + t.Fatalf("removedWhitelist: %v", body["removedWhitelist"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("whitelist list after force: %d %v", code, body) + } + if len(body["items"].([]any)) != 0 { + t.Fatalf("expected whitelist cleared, got %v", body) + } +} + +func TestBlacklistImportForceAlias(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + whitelistPhones(t, e, "09125550011") + + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09125550011"}, + "force": true, + }) + if code != http.StatusOK { + t.Fatalf("force import: %d %v", code, body) + } + if body["inserted"] != float64(1) || body["removedWhitelist"] != float64(1) { + t.Fatalf("force result: %v", body) + } +} + +func TestBlacklistImportRejectsConsumedWhitelist(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09125550020") + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.220", + }, registerBody(t, e, "09125550020", "bl-consumed@test.dev", "blconsumed")) + if code != http.StatusCreated { + t.Fatalf("register: %d %v", code, body) + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09125550020"}, + "resolveConflicts": true, + }) + if code != http.StatusOK { + t.Fatalf("import: %d %v", code, body) + } + if body["rejected"] != float64(1) || body["inserted"] != float64(0) { + t.Fatalf("expected rejected, got %v", body) + } + rejectedPhones, ok := body["rejectedPhones"].([]any) + if !ok || len(rejectedPhones) != 1 || rejectedPhones[0] != "09125550020" { + t.Fatalf("rejectedPhones: %v", body["rejectedPhones"]) + } +} + +func TestBlacklistedPhoneCannotPublicRegister(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09125550030") + blacklistPhones(t, e, "09125550030") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.230", + }, registerBody(t, e, "09125550030", "bl-pub@test.dev", "blpub")) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden, got %d %v", code, body) + } + if body["error"] != "این شماره مجاز به ثبت‌نام نیست" { + t.Fatalf("error: %v", body["error"]) + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/phone-blacklist/attempts?phone=09125550030", admin, nil) + if code != http.StatusOK { + t.Fatalf("attempts: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) != 1 { + t.Fatalf("expected 1 attempt, got %v", body) + } + a := entries[0].(map[string]any) + if a["path"] != "public" || a["phone"] != "09125550030" { + t.Fatalf("unexpected attempt: %v", a) + } + if a["attemptedUsername"] != "blpub" || a["attemptedEmail"] != "bl-pub@test.dev" { + t.Fatalf("attempt identity: %v", a) + } +} + +func TestBlacklistedPhoneCannotInviteRegister(t *testing.T) { + e := setup(t) + blacklistPhones(t, e, "09125550040") + inv := createInvite(t, e, 2, "", nil) + token := inv["token"].(string) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.240", + }, registerBody(t, e, "09125550040", "bl-inv@test.dev", "blinv")) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden, got %d %v", code, body) + } + if body["error"] != "این شماره مجاز به ثبت‌نام نیست" { + t.Fatalf("error: %v", body["error"]) + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/phone-blacklist/attempts", admin, nil) + if code != http.StatusOK { + t.Fatalf("attempts: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) < 1 { + t.Fatalf("expected attempt history, got %v", body) + } + found := false + for _, raw := range entries { + a := raw.(map[string]any) + if a["phone"] == "09125550040" && a["path"] == "invite" { + found = true + if a["inviteId"] == nil { + t.Fatalf("invite attempt missing inviteId: %v", a) + } + break + } + } + if !found { + t.Fatalf("invite attempt not found: %v", entries) + } +} + +func TestPublicRegisterStillRequiresWhitelist(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.250", + }, registerBody(t, e, "09125550050", "still-wl@test.dev", "stillwl")) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden without whitelist, got %d %v", code, body) + } + if body["error"] != "این شماره در فهرست مجاز نیست" { + t.Fatalf("error: %v", body["error"]) + } +} + +func TestBlacklistListPagination(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + phones := make([]string, 0, 12) + for i := 1; i <= 12; i++ { + phones = append(phones, fmt.Sprintf("0912556%04d", i)) + } + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": phones, + }) + if code != http.StatusOK { + t.Fatalf("import: %d %v", code, body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist?page=1&pageSize=5", admin, nil) + if code != http.StatusOK { + t.Fatalf("list page1: %d %v", code, body) + } + if len(body["items"].([]any)) != 5 { + t.Fatalf("page1 len: %d", len(body["items"].([]any))) + } + if body["total"] != float64(12) || body["page"] != float64(1) || body["pageSize"] != float64(5) { + t.Fatalf("meta: %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist?page=3&pageSize=5", admin, nil) + if code != http.StatusOK { + t.Fatalf("list page3: %d %v", code, body) + } + if len(body["items"].([]any)) != 2 { + t.Fatalf("page3 len: %d", len(body["items"].([]any))) + } + + // Seed attempts via blocked register tries. + enableRegistration(t, e) + for i := 1; i <= 7; i++ { + phone := fmt.Sprintf("0912556%04d", i) + code, _ = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": fmt.Sprintf("203.0.113.%d", 100+i), + }, registerBody(t, e, phone, fmt.Sprintf("bl-page-%d@test.dev", i), fmt.Sprintf("blpage%d", i))) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden attempt %d, got %d", i, code) + } + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist/attempts?page=1&pageSize=3", admin, nil) + if code != http.StatusOK { + t.Fatalf("attempts page1: %d %v", code, body) + } + if len(body["items"].([]any)) != 3 { + t.Fatalf("attempts page1 len: %d", len(body["items"].([]any))) + } + total := body["total"].(float64) + if total < 7 { + t.Fatalf("attempts total: %v", total) + } + if body["pageSize"] != float64(3) { + t.Fatalf("attempts pageSize: %v", body["pageSize"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist/attempts?page=1&pageSize=3&q=09125560001", admin, nil) + if code != http.StatusOK { + t.Fatalf("filtered attempts: %d %v", code, body) + } + if body["total"].(float64) < 1 { + t.Fatalf("filtered total: %v", body["total"]) + } + for _, raw := range body["items"].([]any) { + if raw.(map[string]any)["phone"] != "09125560001" { + t.Fatalf("unexpected phone in filtered page: %v", raw) + } + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist?page=1&pageSize=10&q=09125560002", admin, nil) + if code != http.StatusOK { + t.Fatalf("blacklist search: %d %v", code, body) + } + if body["total"] != float64(1) { + t.Fatalf("blacklist search total: %v", body["total"]) + } + items := body["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["phone"] != "09125560002" { + t.Fatalf("blacklist search items: %v", items) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist?page=1", admin, nil) + if code != http.StatusOK { + t.Fatalf("default pageSize: %d %v", code, body) + } + if body["pageSize"] != float64(10) { + t.Fatalf("expected default pageSize 10, got %v", body["pageSize"]) + } +} diff --git a/backend/internal/api/captcha.go b/backend/internal/api/captcha.go new file mode 100644 index 0000000..c1aaee0 --- /dev/null +++ b/backend/internal/api/captcha.go @@ -0,0 +1,350 @@ +package api + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "net/http" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + captchaTTL = 5 * time.Minute + captchaKeyPrefix = "pargar:captcha:" + captchaRateLimit int64 = 60 + captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + captchaMinLen = 4 + captchaMaxLen = 5 + captchaImgW = 160 + captchaImgH = 52 +) + +var ( + errCaptchaUnavailable = errors.New("captcha unavailable") + errCaptchaRequired = errors.New("captcha required") + errCaptchaInvalid = errors.New("captcha invalid") + + // 5×7 bitmap glyphs for A–Z and 0–9 (row-major, MSB = left). + captchaGlyphs = map[byte][7]byte{ + 'A': {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}, + 'B': {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E}, + 'C': {0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E}, + 'D': {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E}, + 'E': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F}, + 'F': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10}, + 'G': {0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E}, + 'H': {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}, + 'J': {0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E}, + 'K': {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11}, + 'L': {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F}, + 'M': {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11}, + 'N': {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11}, + 'P': {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10}, + 'Q': {0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D}, + 'R': {0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11}, + 'S': {0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E}, + 'T': {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}, + 'U': {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}, + 'V': {0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04}, + 'W': {0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11}, + 'X': {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11}, + 'Y': {0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04}, + 'Z': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F}, + '2': {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F}, + '3': {0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E}, + '4': {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02}, + '5': {0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E}, + '6': {0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E}, + '7': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}, + '8': {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E}, + '9': {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C}, + } +) + +func captchaAnswerHash(answer string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToUpper(answer)))) + return hex.EncodeToString(sum[:]) +} + +func randBounded(maxExclusive int) (int, error) { + if maxExclusive <= 0 { + return 0, fmt.Errorf("invalid bound") + } + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, err + } + return int(binary.BigEndian.Uint64(buf[:]) % uint64(maxExclusive)), nil +} + +func randomCaptchaText() (string, error) { + n := captchaMinLen + extra, err := randBounded(captchaMaxLen - captchaMinLen + 1) + if err != nil { + return "", err + } + n += extra + out := make([]byte, n) + for i := range out { + idx, err := randBounded(len(captchaCharset)) + if err != nil { + return "", err + } + out[i] = captchaCharset[idx] + } + return string(out), nil +} + +func renderCaptchaPNG(text string) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, captchaImgW, captchaImgH)) + bg := color.RGBA{R: 248, G: 250, B: 252, A: 255} + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + + // Light speckled noise. + for i := 0; i < 180; i++ { + x, err := randBounded(captchaImgW) + if err != nil { + return nil, err + } + y, err := randBounded(captchaImgH) + if err != nil { + return nil, err + } + shade, err := randBounded(80) + if err != nil { + return nil, err + } + c := uint8(160 + shade) + img.Set(x, y, color.RGBA{R: c, G: c, B: c, A: 255}) + } + + // A few interference lines. + ink := color.RGBA{R: 30, G: 41, B: 59, A: 255} + for i := 0; i < 3; i++ { + x0, err := randBounded(captchaImgW) + if err != nil { + return nil, err + } + y0, err := randBounded(captchaImgH) + if err != nil { + return nil, err + } + x1, err := randBounded(captchaImgW) + if err != nil { + return nil, err + } + y1, err := randBounded(captchaImgH) + if err != nil { + return nil, err + } + drawLine(img, x0, y0, x1, y1, color.RGBA{R: 148, G: 163, B: 184, A: 180}) + } + + scale := 3 + glyphW := 5 * scale + gap := 4 + totalW := len(text)*glyphW + (len(text)-1)*gap + startX := (captchaImgW - totalW) / 2 + if startX < 4 { + startX = 4 + } + baseY := (captchaImgH - 7*scale) / 2 + + for i := 0; i < len(text); i++ { + ch := text[i] + glyph, ok := captchaGlyphs[ch] + if !ok { + continue + } + jitter, err := randBounded(5) + if err != nil { + return nil, err + } + ox := startX + i*(glyphW+gap) + oy := baseY + jitter - 2 + drawGlyph(img, ox, oy, glyph, scale, ink) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func drawGlyph(img *image.RGBA, ox, oy int, glyph [7]byte, scale int, c color.RGBA) { + for row := 0; row < 7; row++ { + bits := glyph[row] + for col := 0; col < 5; col++ { + if bits&(1<= 0 && y >= 0 && x < img.Bounds().Dx() && y < img.Bounds().Dy() { + img.Set(x, y, c) + } + } + } + } + } +} + +func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) { + dx := x1 - x0 + if dx < 0 { + dx = -dx + } + dy := y1 - y0 + if dy < 0 { + dy = -dy + } + sx, sy := 1, 1 + if x0 > x1 { + sx = -1 + } + if y0 > y1 { + sy = -1 + } + err := dx - dy + for { + if x0 >= 0 && y0 >= 0 && x0 < img.Bounds().Dx() && y0 < img.Bounds().Dy() { + img.Set(x0, y0, c) + } + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x0 += sx + } + if e2 < dx { + err += dx + y0 += sy + } + } +} + +func captchaImageBase64(text string) (string, error) { + pngBytes, err := renderCaptchaPNG(text) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(pngBytes), nil +} + +func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) { + if s.redis == nil { + if s.cfg.DevMode { + img, err := captchaImageBase64("A2B3") + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد") + return + } + resp := map[string]any{ + "challengeId": "dev", + "imageBase64": img, + } + if s.cfg.CaptchaExposeAnswer { + resp["answer"] = "A2B3" + } + writeJSON(w, http.StatusOK, resp) + return + } + writeErr(w, http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست") + return + } + + text, err := randomCaptchaText() + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد") + return + } + img, err := captchaImageBase64(text) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد") + return + } + + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + writeErr(w, http.StatusInternalServerError, "صدور کد امنیتی ممکن نشد") + return + } + id := hex.EncodeToString(raw) + key := captchaKeyPrefix + id + if err := s.redis.Set(r.Context(), key, captchaAnswerHash(text), captchaTTL).Err(); err != nil { + writeErr(w, http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست") + return + } + + resp := map[string]any{ + "challengeId": id, + "imageBase64": img, + } + // Never expose plaintext answers in production. Local tests may opt in via + // CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true. + if s.cfg.DevMode && s.cfg.CaptchaExposeAnswer { + resp["answer"] = text + } + writeJSON(w, http.StatusOK, resp) +} + +// consumeCaptcha verifies and one-time-deletes a challenge. On any failure the +// challenge is consumed (or already gone) so the client must fetch a new one. +func (s *Server) consumeCaptcha(ctx context.Context, challengeID, answer string) error { + challengeID = strings.TrimSpace(challengeID) + answer = strings.TrimSpace(answer) + if challengeID == "" || answer == "" { + return errCaptchaRequired + } + + if s.redis == nil { + if s.cfg.DevMode && challengeID == "dev" && strings.EqualFold(answer, "A2B3") { + return nil + } + return errCaptchaUnavailable + } + + key := captchaKeyPrefix + challengeID + stored, err := s.redis.GetDel(ctx, key).Result() + if err == redis.Nil { + return errCaptchaInvalid + } + if err != nil { + return errCaptchaUnavailable + } + if stored != captchaAnswerHash(answer) { + return errCaptchaInvalid + } + return nil +} + +func captchaErrorMessage(err error) (int, string) { + switch { + case errors.Is(err, errCaptchaRequired): + return http.StatusBadRequest, "کد امنیتی الزامی است" + case errors.Is(err, errCaptchaInvalid): + return http.StatusBadRequest, "پاسخ کد امنیتی نادرست یا منقضی است" + case errors.Is(err, errCaptchaUnavailable): + return http.StatusServiceUnavailable, "سرویس کد امنیتی در دسترس نیست" + default: + return http.StatusBadRequest, "پاسخ کد امنیتی نادرست یا منقضی است" + } +} diff --git a/backend/internal/api/captcha_test.go b/backend/internal/api/captcha_test.go new file mode 100644 index 0000000..9a40769 --- /dev/null +++ b/backend/internal/api/captcha_test.go @@ -0,0 +1,134 @@ +package api_test + +import ( + "context" + "encoding/base64" + "net/http" + "testing" + "time" +) + +func TestCaptchaRequiredOnLogin(t *testing.T) { + e := setup(t) + code, body := e.do(t, "POST", "/api/auth/login", "", map[string]any{ + "email": bootstrapAdminEmail, "password": "password123", + }) + if code != http.StatusBadRequest || body["error"] != "کد امنیتی الزامی است" { + t.Fatalf("expected captcha required, got %d %v", code, body) + } +} + +func TestCaptchaImageChallenge(t *testing.T) { + e := setup(t) + code, body := e.do(t, "GET", "/api/auth/captcha", "", nil) + if code != http.StatusOK { + t.Fatalf("captcha: %d %v", code, body) + } + id, _ := body["challengeId"].(string) + img, _ := body["imageBase64"].(string) + answer, _ := body["answer"].(string) + if id == "" || img == "" || answer == "" { + t.Fatalf("captcha missing fields: %v", body) + } + if _, ok := body["question"]; ok { + t.Fatalf("legacy question field should be gone: %v", body) + } + raw, err := base64.StdEncoding.DecodeString(img) + if err != nil || len(raw) < 32 { + t.Fatalf("imageBase64 not valid PNG payload: err=%v len=%d", err, len(raw)) + } + if raw[0] != 0x89 || string(raw[1:4]) != "PNG" { + t.Fatalf("imageBase64 is not a PNG") + } + if len(answer) < 4 || len(answer) > 5 { + t.Fatalf("unexpected answer length: %q", answer) + } +} + +func TestCaptchaWrongRejected(t *testing.T) { + e := setup(t) + id, answer := fetchCaptcha(t, e) + code, body := e.do(t, "POST", "/api/auth/login", "", map[string]any{ + "email": bootstrapAdminEmail, "password": "password123", + "challengeId": id, "captchaAnswer": "ZZZZZ", + }) + if code != http.StatusBadRequest || body["error"] != "پاسخ کد امنیتی نادرست یا منقضی است" { + t.Fatalf("expected wrong captcha, got %d %v", code, body) + } + // Challenge is one-time: reuse must fail even with the correct answer later. + code, body = e.do(t, "POST", "/api/auth/login", "", map[string]any{ + "email": bootstrapAdminEmail, "password": "password123", + "challengeId": id, "captchaAnswer": answer, + }) + if code != http.StatusBadRequest { + t.Fatalf("reused challenge should fail, got %d %v", code, body) + } +} + +func TestRememberMeRefreshTTL(t *testing.T) { + e := setup(t) + ctx := context.Background() + + assertTTL := func(remember bool, wantMin, wantMax time.Duration) { + t.Helper() + body := loginBody(t, e, bootstrapAdminEmail, "password123") + body["rememberMe"] = remember + code, resp := e.do(t, "POST", "/api/auth/login", "", body) + if code != http.StatusOK { + t.Fatalf("login rememberMe=%v: %d %v", remember, code, resp) + } + var lifetime time.Duration + err := e.pool.QueryRow(ctx, ` + SELECT expires_at - created_at + FROM refresh_tokens + WHERE revoked_at IS NULL + ORDER BY id DESC + LIMIT 1`).Scan(&lifetime) + if err != nil { + t.Fatalf("query refresh ttl: %v", err) + } + if lifetime < wantMin || lifetime > wantMax { + t.Fatalf("rememberMe=%v: lifetime %v not in [%v, %v]", remember, lifetime, wantMin, wantMax) + } + } + + assertTTL(false, 23*time.Hour, 25*time.Hour) + assertTTL(true, 29*24*time.Hour, 31*24*time.Hour) +} + +func TestCaptchaCorrectLogin(t *testing.T) { + e := setup(t) + code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, bootstrapAdminEmail, "password123")) + if code != http.StatusOK { + t.Fatalf("login with captcha: %d %v", code, body) + } + if body["accessToken"] == nil { + t.Fatalf("missing access token: %v", body) + } +} + +func TestSuccessfulLoginClearsPenalty(t *testing.T) { + e := setup(t) + // Accumulate a few failures (wrong password with valid captcha). + for i := 0; i < 4; i++ { + code, _ := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong")) + if code != http.StatusUnauthorized { + t.Fatalf("fail %d: %d", i+1, code) + } + } + // Successful login should clear the fail counter for this IP. + code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, bootstrapAdminEmail, "password123")) + if code != http.StatusOK { + t.Fatalf("success login: %d %v", code, body) + } + // After clear, several more wrong attempts should still be allowed (not immediately locked to 2/min). + for i := 0; i < 5; i++ { + code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong")) + if code == http.StatusTooManyRequests { + t.Fatalf("should not be rate-limited so soon after successful login clear, attempt %d: %v", i+1, body) + } + if code != http.StatusUnauthorized { + t.Fatalf("attempt %d: %d %v", i+1, code, body) + } + } +} diff --git a/backend/internal/api/certificate.go b/backend/internal/api/certificate.go new file mode 100644 index 0000000..37238eb --- /dev/null +++ b/backend/internal/api/certificate.go @@ -0,0 +1,288 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +func newPublicID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) { + ok, err := s.store.HasCompletedCurriculum(ctx, user.ID) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + if existing, err := s.store.GetCertificateByUser(ctx, user.ID); err == nil { + if existing.RevokedAt == nil { + return existing, false, nil + } + } else if err != store.ErrNotFound { + return nil, false, err + } + publicID, err := newPublicID() + if err != nil { + return nil, false, err + } + cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name) + if err != nil { + return nil, false, err + } + return cert, true, nil +} + +func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + cert, created, err := s.issueCertificateIfEligible(r.Context(), u) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور گواهینامه ممکن نشد") + return + } + if cert == nil { + writeErr(w, http.StatusConflict, "هنوز همه درس‌های فعال را پاس نکرده‌اید") + return + } + if created { + observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) + } + writeJSON(w, http.StatusOK, map[string]any{"certificate": cert, "created": created}) +} + +func (s *Server) handleMyCertificate(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + eligible, err := s.store.HasCompletedCurriculum(r.Context(), u.ID) + if err != nil { + writeInternalErr(w, "certificate.eligible", err, "بارگذاری وضعیت گواهینامه ممکن نشد") + return + } + cert, err := s.store.GetCertificateByUser(r.Context(), u.ID) + if err != nil && err != store.ErrNotFound { + writeInternalErr(w, "certificate.mine", err, "بارگذاری گواهینامه ممکن نشد") + return + } + if err == store.ErrNotFound || (cert != nil && cert.RevokedAt != nil) { + cert = nil + } + order, _ := s.store.GetLatestPhysicalOrder(r.Context(), u.ID) + writeJSON(w, http.StatusOK, map[string]any{ + "certificate": cert, + "eligible": eligible, + "physicalOrder": order, + "physical": s.physicalSettings(r.Context()), + }) +} + +func setCertificateNoIndex(w http.ResponseWriter) { + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") +} + +func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request) { + setCertificateNoIndex(w) + publicID := r.PathValue("publicId") + if publicID == "" { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID) + if err != nil || cert.RevokedAt != nil { + writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "certificate": map[string]any{ + "publicId": cert.PublicID, + "fullName": cert.FullName, + "issuedAt": cert.IssuedAt, + }, + "course": "پرگار", + }) +} + +func (s *Server) physicalSettings(ctx context.Context) map[string]any { + enabled := s.cfg.PhysicalCertEnabled + price := s.cfg.PhysicalCertPriceIRR + days := s.cfg.PhysicalCertWindowDays + if v, err := s.store.GetSetting(ctx, "physical_cert_enabled"); err == nil { + enabled = v == "true" || v == "1" + } + if v, err := s.store.GetSetting(ctx, "physical_cert_price_irr"); err == nil { + if n, e := strconv.Atoi(v); e == nil { + price = n + } + } + if v, err := s.store.GetSetting(ctx, "physical_cert_window_days"); err == nil { + if n, e := strconv.Atoi(v); e == nil { + days = n + } + } + return map[string]any{ + "enabled": enabled, + "priceIrr": price, + "windowDays": days, + } +} + +func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + settings := s.physicalSettings(r.Context()) + if enabled, _ := settings["enabled"].(bool); !enabled { + writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست") + return + } + cert, err := s.store.GetCertificateByUser(r.Context(), u.ID) + if err != nil || cert.RevokedAt != nil { + writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید") + return + } + days, _ := settings["windowDays"].(int) + if days <= 0 { + days = 30 + } + windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour) + if time.Now().After(windowEnds) { + writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است") + return + } + if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil { + if existing.Status != models.PhysicalCancelled { + writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید") + return + } + } + var req struct { + RecipientName string `json:"recipientName"` + Phone string `json:"phone"` + Address string `json:"address"` + City string `json:"city"` + PostalCode string `json:"postalCode"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + ship := models.CertificatePhysicalOrder{ + RecipientName: strings.TrimSpace(req.RecipientName), + Phone: normalizePhone(req.Phone), + Address: strings.TrimSpace(req.Address), + City: strings.TrimSpace(req.City), + PostalCode: strings.TrimSpace(req.PostalCode), + } + if ship.RecipientName == "" { + ship.RecipientName = u.Name + } + if ship.Phone == "" { + ship.Phone = normalizePhone(u.Phone) + } + if ship.Phone == "" || !validPhone(ship.Phone) { + writeErr(w, http.StatusBadRequest, msgPhoneInvalid) + return + } + if ship.Address == "" || ship.City == "" { + writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است") + return + } + if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 { + writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است") + return + } + order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد") + return + } + staff, _ := s.store.ListMentors(r.Context()) + go func() { + for _, m := range staff { + if m.Role != models.RoleAdmin { + continue + } + _ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order", + "سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin", + map[string]any{"orderId": order.ID, "userId": u.ID}) + } + }() + observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID) + writeJSON(w, http.StatusCreated, map[string]any{ + "order": order, + "message": "پس از واریز با ادمین هماهنگ کنید", + "physical": settings, + }) +} + +func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + orders, err := s.store.ListPhysicalOrders(r.Context(), status) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())}) +} + +func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var req struct { + Status string `json:"status"` + Note string `json:"note"` + TrackingCode string `json:"trackingCode"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + var st models.PhysicalOrderStatus + switch req.Status { + case "requested", "paid", "shipped", "cancelled": + st = models.PhysicalOrderStatus(req.Status) + default: + writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است") + return + } + order, err := s.store.UpdatePhysicalOrder(r.Context(), id, st, req.Note, strings.TrimSpace(req.TrackingCode)) + if err != nil { + writeErr(w, http.StatusNotFound, "سفارش پیدا نشد") + return + } + title, body, route := physicalStatusCopy(st, order.TrackingCode) + go func() { + _ = s.notify.Notify(context.Background(), order.UserID, "progress", "physical_order_"+string(st), + title, body, route, map[string]any{"orderId": order.ID, "status": string(st)}) + }() + observability.Activity("certificate.physical_updated", "order_id", id, "status", string(st), "actor_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"order": order}) +} + +func physicalStatusCopy(st models.PhysicalOrderStatus, tracking string) (title, body, route string) { + route = "/notifications" + switch st { + case models.PhysicalPaid: + return "پرداخت گواهینامه تأیید شد", "وضعیت سفارش فیزیکی به «پرداخت‌شده» تغییر کرد.", route + case models.PhysicalShipped: + msg := "گواهینامه فیزیکی ارسال شد." + if tracking != "" { + msg += " کد پیگیری: " + tracking + } + return "گواهینامه ارسال شد", msg, route + case models.PhysicalCancelled: + return "سفارش فیزیکی لغو شد", "سفارش نسخه فیزیکی شما لغو شد. در صورت نیاز دوباره درخواست دهید.", route + default: + return "به‌روزرسانی سفارش فیزیکی", "وضعیت سفارش گواهینامه فیزیکی تغییر کرد.", route + } +} diff --git a/backend/internal/api/challenges.go b/backend/internal/api/challenges.go new file mode 100644 index 0000000..4011f87 --- /dev/null +++ b/backend/internal/api/challenges.go @@ -0,0 +1,108 @@ +package api + +import ( + "net/http" + "strings" + "unicode/utf8" + + "pargar/backend/internal/models" + "pargar/backend/internal/store" +) + +func (s *Server) handleListChallenges(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + list, err := s.store.ListActiveChallengesForUser(r.Context(), u.ID) + if err != nil { + writeInternalErr(w, "challenges.list", err, "بارگذاری چالش‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"challenges": list}) +} + +func (s *Server) handleAdminListChallenges(w http.ResponseWriter, r *http.Request) { + list, err := s.store.ListChallengesAdmin(r.Context()) + if err != nil { + writeInternalErr(w, "admin.challenges.list", err, "بارگذاری چالش‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"challenges": list}) +} + +func (s *Server) handleAdminCreateChallenge(w http.ResponseWriter, r *http.Request) { + admin := currentUser(r) + var c models.Challenge + if err := bodyJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if msg := validateChallenge(&c); msg != "" { + writeValidationErr(w, msg) + return + } + c.CreatedBy = &admin.ID + id, err := s.store.CreateChallenge(r.Context(), &c) + if err != nil { + writeInternalErr(w, "admin.challenges.create", err, "ساخت چالش ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"challenge": c, "id": id}) +} + +func (s *Server) handleAdminUpdateChallenge(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var c models.Challenge + if err := bodyJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if msg := validateChallenge(&c); msg != "" { + writeValidationErr(w, msg) + return + } + if err := s.store.UpdateChallenge(r.Context(), id, &c); err != nil { + if err == store.ErrNotFound { + writeErr(w, http.StatusNotFound, "چالش پیدا نشد") + return + } + writeInternalErr(w, "admin.challenges.update", err, "به‌روزرسانی چالش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminDeleteChallenge(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + if err := s.store.DeleteChallenge(r.Context(), id); err != nil { + if err == store.ErrNotFound { + writeErr(w, http.StatusNotFound, "چالش پیدا نشد") + return + } + writeInternalErr(w, "admin.challenges.delete", err, "حذف چالش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func validateChallenge(c *models.Challenge) string { + c.Title = strings.TrimSpace(c.Title) + c.Description = strings.TrimSpace(c.Description) + if c.Title == "" || c.StartsAt.IsZero() || c.EndsAt.IsZero() { + return "عنوان، زمان شروع و زمان پایان الزامی هستند" + } + if utf8.RuneCountInString(c.Title) > 200 { + return "عنوان چالش خیلی طولانی است" + } + if utf8.RuneCountInString(c.Description) > 5000 { + return "توضیح چالش خیلی طولانی است" + } + if c.TargetXP <= 0 { + return "هدف امتیاز باید بزرگ‌تر از صفر باشد" + } + if c.TargetXP > 1_000_000 { + return "هدف امتیاز خیلی بزرگ است" + } + if c.EndsAt.Before(c.StartsAt) { + return "زمان پایان باید پس از زمان شروع باشد" + } + return "" +} diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go new file mode 100644 index 0000000..4c27c0f --- /dev/null +++ b/backend/internal/api/chat.go @@ -0,0 +1,512 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/store" +) + +const maxUploadSize = 25 << 20 // 25 MB + +func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) { + actor := currentUser(r) + var mentors []models.User + var err error + if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin { + mentors, _, err = s.store.ListUsers(r.Context(), "", "", 100, 0) + } else { + mentors, err = s.store.ListMentors(r.Context()) + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری منتورها ممکن نشد") + return + } + safe := make([]map[string]any, 0, len(mentors)) + for _, m := range mentors { + if m.ID == actor.ID || !m.IsActive { + continue + } + if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin { + continue + } + safe = append(safe, map[string]any{ + "id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role, + "avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"mentors": safe}) +} + +type conversation struct { + Partner map[string]any `json:"partner"` + LastMessage *models.ChatMessage `json:"lastMessage,omitempty"` + UnreadCount int `json:"unreadCount"` +} + +func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + rows, err := s.store.Pool().Query(r.Context(), ` + SELECT DISTINCT partner, u2.id, u2.name, u2.email, u2.role, + u2.avatar_variant, u2.avatar_palette, u2.avatar_photo + FROM ( + SELECT user_id AS partner FROM chat_messages WHERE mentor_id=$1 + UNION + SELECT mentor_id FROM chat_messages WHERE user_id=$1 + ) p + JOIN users u2 ON u2.id = p.partner + ORDER BY u2.id`, u.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد") + return + } + type pmeta struct { + ID int64 + Name string + Email string + Role models.Role + AvatarVariant string + AvatarPalette string + AvatarPhoto string + } + var partners []pmeta + for rows.Next() { + var p pmeta + if err := rows.Scan(&p.ID, &p.ID, &p.Name, &p.Email, &p.Role, + &p.AvatarVariant, &p.AvatarPalette, &p.AvatarPhoto); err != nil { + rows.Close() + writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد") + return + } + partners = append(partners, p) + } + rows.Close() + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری گفتگوها ممکن نشد") + return + } + + out := make([]conversation, 0, len(partners)) + for _, p := range partners { + if !s.canChatWith(r.Context(), u, p.ID) { + continue + } + msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100) + if err != nil { + continue + } + var last *models.ChatMessage + unread := 0 + for i := range msgs { + m := msgs[i] + last = &m + if m.ReadAt == nil && m.SenderRole != u.Role { + unread++ + } + } + conv := conversation{ + Partner: map[string]any{ + "id": p.ID, "name": p.Name, "email": p.Email, "role": p.Role, + "avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": p.AvatarPhoto, + "online": s.hub.Online(p.ID), + }, + UnreadCount: unread, + } + if last != nil { + conv.LastMessage = last + } + out = append(out, conv) + } + if out == nil { + out = []conversation{} + } + writeJSON(w, http.StatusOK, map[string]any{"conversations": out}) +} + +func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + + if raw := r.URL.Query().Get("around"); raw != "" { + aroundID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || aroundID <= 0 { + writeErr(w, http.StatusBadRequest, "شناسهٔ پیام نامعتبر است") + return + } + ok, err := s.store.ChatMessageInPair(r.Context(), u.ID, partner, aroundID) + if err != nil || !ok { + writeErr(w, http.StatusNotFound, "پیام پیدا نشد") + return + } + const pageSize = 60 + msgs, hasMoreBefore, hasMoreAfter, err := s.store.ListChatMessagesAround(r.Context(), u.ID, partner, aroundID, pageSize) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پیام‌ها ممکن نشد") + return + } + _ = s.store.FillReactions(r.Context(), u.ID, msgs) + _ = s.store.MarkChatRead(r.Context(), u.ID, partner) + s.resignChatMessages(msgs) + pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) + if pinned == nil { + pinned = []models.ChatMessage{} + } + _ = s.store.FillReactions(r.Context(), u.ID, pinned) + s.resignChatMessages(pinned) + writeJSON(w, http.StatusOK, map[string]any{ + "messages": msgs, + "hasMore": hasMoreBefore, + "hasMoreBefore": hasMoreBefore, + "hasMoreAfter": hasMoreAfter, + "around": aroundID, + "pinned": pinned, + }) + return + } + + before := int64(0) + if raw := r.URL.Query().Get("before"); raw != "" { + v, err := strconv.ParseInt(raw, 10, 64) + if err == nil && v > 0 { + before = v + } + } + const pageSize = 60 + msgs, err := s.store.ListChatMessages(r.Context(), u.ID, partner, before, pageSize+1) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پیام‌ها ممکن نشد") + return + } + hasMore := int64(len(msgs)) > pageSize + nextBefore := int64(0) + if hasMore { + msgs = msgs[1:] + if len(msgs) > 0 { + nextBefore = msgs[0].ID + } + } + _ = s.store.FillReactions(r.Context(), u.ID, msgs) + _ = s.store.MarkChatRead(r.Context(), u.ID, partner) + s.resignChatMessages(msgs) + pinned, _ := s.store.ListPinnedChatMessages(r.Context(), u.ID, partner) + if pinned == nil { + pinned = []models.ChatMessage{} + } + _ = s.store.FillReactions(r.Context(), u.ID, pinned) + s.resignChatMessages(pinned) + writeJSON(w, http.StatusOK, map[string]any{ + "messages": msgs, "hasMore": hasMore, "nextBefore": nextBefore, "pinned": pinned, + }) +} + +func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partnerID := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partnerID) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + var req struct { + Body string `json:"body"` + ReplyTo *int64 `json:"replyTo"` + Attachment *models.Attachment `json:"attachment,omitempty"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + req.Body = strings.TrimSpace(req.Body) + if att := req.Attachment; att != nil { + if att.Size > maxUploadSize { + writeErr(w, http.StatusBadRequest, "حجم پیوست بیش از حد مجاز است") + return + } + att.Type = strings.ToLower(att.Type) + if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" { + writeErr(w, http.StatusBadRequest, "نوع پیوست پشتیبانی نمی‌شود") + return + } + if att.URL == "" || !strings.Contains(att.URL, "/media/") { + writeErr(w, http.StatusBadRequest, "نشانی پیوست نامعتبر است") + return + } + key := mediaKeyFromURL(att.URL) + if !mediaKeyOwnedByUser(key, u.ID) { + writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست") + return + } + // Persist without signature query — re-signed when messages are returned. + if q := strings.Index(att.URL, "?"); q >= 0 { + att.URL = att.URL[:q] + } + if att.Name == "" { + att.Name = "file" + } + } + if req.Body == "" && req.Attachment == nil { + writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد") + return + } + if len(req.Body) > 4000 { + writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد") + return + } + + partner, err := s.store.GetUserByID(r.Context(), partnerID) + if err != nil { + writeErr(w, http.StatusNotFound, "طرف گفتگو پیدا نشد") + return + } + studentID := u.ID + mentorID := partner.ID + if u.Role == models.RoleMentor || u.Role == models.RoleAdmin { + studentID = partner.ID + mentorID = u.ID + } + + msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, req.Attachment) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد") + return + } + s.resignChatMessage(msg) + + // live push chat_message to both participants + s.hub.Push(u.ID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) + s.hub.Push(partnerID, map[string]any{"type": "chat_message", "item": msg, "from": u.ID}) + + // notify the recipient + if u.Role == models.RoleMentor || u.Role == models.RoleAdmin { + _ = s.notify.Notify(context.Background(), studentID, "mentor", "mentor_reply", + "پاسخ منتور", u.Name+" برای شما پیام فرستاد.", "/unwrap?tab=chats&with="+itoa(u.ID), nil) + } else { + _ = s.notify.Notify(context.Background(), mentorID, "mentor", "student_message", + "پیام از "+u.Name, "یک هنرجو به کمک شما نیاز دارد.", "/unwrap?tab=chats&with="+itoa(u.ID), nil) + } + writeJSON(w, http.StatusCreated, map[string]any{"message": msg}) +} + +func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + msgID := routeID(r, "id") + var req struct { + Emoji string `json:"emoji"` + } + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + req.Emoji = strings.TrimSpace(req.Emoji) + if len(req.Emoji) > 16 { + writeErr(w, http.StatusBadRequest, "شکلک نامعتبر است") + return + } + ok, err := s.store.MessageInConversation(r.Context(), msgID, u.ID, partner) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پیام ممکن نشد") + return + } + if !ok { + writeErr(w, http.StatusNotFound, "پیام پیدا نشد") + return + } + emoji, reactions, err := s.store.ReactToChatMessage(r.Context(), msgID, u.ID, req.Emoji) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ واکنش ممکن نشد") + return + } + event := map[string]any{"type": "chat_reaction", "messageId": msgID, "from": u.ID, "emoji": emoji} + s.hub.Push(u.ID, event) + s.hub.Push(partner, event) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions}) +} + +func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + msgID := routeID(r, "id") + ok, err := s.store.DeleteChatMessage(r.Context(), u.ID, partner, msgID, u.Role) + if err != nil { + writeErr(w, http.StatusInternalServerError, "حذف پیام ممکن نشد") + return + } + if !ok { + writeErr(w, http.StatusNotFound, "پیام پیدا نشد") + return + } + s.hub.Push(u.ID, map[string]any{"type": "chat_delete", "id": msgID, "from": u.ID}) + s.hub.Push(partner, map[string]any{"type": "chat_delete", "id": msgID, "from": u.ID}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleChatPin(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + msgID := routeID(r, "id") + msg, err := s.store.TogglePinChatMessage(r.Context(), u.ID, partner, msgID) + if errors.Is(err, store.ErrNotFound) { + writeErr(w, http.StatusNotFound, "پیام پیدا نشد") + return + } + if err != nil { + writeErr(w, http.StatusInternalServerError, "پین پیام ممکن نشد") + return + } + s.resignChatMessage(msg) + event := map[string]any{"type": "chat_pin", "from": u.ID, "message": msg} + s.hub.Push(u.ID, event) + s.hub.Push(partner, event) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg}) +} + +func (s *Server) handleChatUpload(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+1<<20) + if err := r.ParseMultipartForm(maxUploadSize + 1<<20); err != nil { + writeErr(w, http.StatusBadRequest, "حجم فایل باید کمتر از ۲۵ مگابایت باشد") + return + } + file, h, err := r.FormFile("file") + if err != nil { + writeErr(w, http.StatusBadRequest, "فایلی ارسال نشده است") + return + } + defer file.Close() + if h.Size == 0 { + writeErr(w, http.StatusBadRequest, "فایل خالی است") + return + } + if h.Size > maxUploadSize { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۲۵ مگابایت باشد") + return + } + + name := filepath.Base(strings.TrimSpace(h.Filename)) + if name == "" { + writeErr(w, http.StatusBadRequest, "نام فایل نامعتبر است") + return + } + accepted, err := acceptChatUpload(h, file) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + + key := fmt.Sprintf("chats/%d-%d%s", u.ID, time.Now().UnixNano(), accepted.Ext) + rawURL, err := s.storage.Save(key, file, h.Size) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد") + return + } + + att := models.Attachment{ + Type: accepted.Kind, + URL: s.signExistingMediaURL(rawURL), Name: name, Size: h.Size, + } + if att.Type == "pdf" || att.Type == "office" || att.Type == "text" { + att.Type = "file" + } + writeJSON(w, http.StatusCreated, map[string]any{"attachment": att}) +} + +var allowedExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, + ".mp4": true, ".webm": true, ".mov": true, ".m4v": true, + ".mp3": true, ".wav": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true, ".weba": true, + ".pdf": true, ".txt": true, ".md": true, ".csv": true, ".json": true, + ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, +} + +func allowedExtension(ext string) bool { + return allowedExts[ext] +} + +func attachmentType(mime, ext string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "video/"): + return "video" + case strings.HasPrefix(mime, "audio/"): + return "audio" + } + // Some browsers send a generic MIME (application/octet-stream); fall back to extension. + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif": + return "image" + case ".mp4", ".webm", ".mov", ".m4v": + return "video" + case ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".weba": + return "audio" + } + return "file" +} + +func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + partner := routeID(r, "partner") + if !s.canChatWith(r.Context(), u, partner) { + writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید") + return + } + if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت خوانده‌شدن ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool { + if actor == nil || partnerID <= 0 || actor.ID == partnerID { + return false + } + partner, err := s.store.GetUserByID(ctx, partnerID) + if err != nil || !partner.IsActive { + return false + } + if actor.Role == models.RoleStudent { + return partner.Role == models.RoleMentor || partner.Role == models.RoleAdmin + } + return actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin +} + +func (s *Server) resignChatMessages(msgs []models.ChatMessage) { + for i := range msgs { + s.resignChatMessage(&msgs[i]) + } +} + +func (s *Server) resignChatMessage(msg *models.ChatMessage) { + if msg == nil || msg.Attachment == nil || msg.Attachment.URL == "" { + return + } + msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL) +} + +func itoa(v int64) string { + return strconvFormatInt(v) +} diff --git a/backend/internal/api/chat_export.go b/backend/internal/api/chat_export.go new file mode 100644 index 0000000..f72b187 --- /dev/null +++ b/backend/internal/api/chat_export.go @@ -0,0 +1,619 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +const ( + chatExportPayloadVersion = 4 + chatExportSchema = "pargar.chat-export" +) + +type chatExportParticipant struct { + ID int64 `json:"id"` + Name string `json:"name"` + Role string `json:"role"` +} + +type chatExportParticipants struct { + Student chatExportParticipant `json:"student"` + Mentor chatExportParticipant `json:"mentor"` +} + +// chatExportAttachment is the v4 archive attachment descriptor. +// Path is relative inside the ZIP (e.g. attachments/1-2/42-photo.jpg). +type chatExportAttachment struct { + Type string `json:"type"` + Name string `json:"name"` + Size int64 `json:"size"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing,omitempty"` + // URL kept for restore of legacy JSON exports (v2/v3) only. + URL string `json:"url,omitempty"` +} + +type chatExportMessage struct { + ID int64 `json:"id,omitempty"` + Body string `json:"body"` + SenderRole string `json:"senderRole"` + CreatedAt time.Time `json:"createdAt"` + ReadAt *time.Time `json:"readAt,omitempty"` + ReplyTo *int64 `json:"replyTo,omitempty"` + Pinned bool `json:"pinned"` + PinnedAt *time.Time `json:"pinnedAt,omitempty"` + Attachment *chatExportAttachment `json:"attachment,omitempty"` +} + +type chatExportConversation struct { + StudentID int64 `json:"studentId"` + StudentName string `json:"studentName"` + MentorID int64 `json:"mentorId"` + MentorName string `json:"mentorName"` + Participants chatExportParticipants `json:"participants"` + MessageCount int `json:"messageCount"` + Messages []chatExportMessage `json:"messages"` +} + +type chatExportPayload struct { + Version int `json:"version"` + Schema string `json:"schema"` + ExportedAt time.Time `json:"exportedAt"` + Format string `json:"format,omitempty"` + ConversationN int `json:"conversationCount"` + Conversations []chatExportConversation `json:"conversations"` +} + +type zipAttachmentFile struct { + Path string + Key string +} + +var unsafeFilenameChars = regexp.MustCompile(`[^a-zA-Z0-9._\-\p{L}]+`) + +func (s *Server) chatExportDownloadURL(rawToken string) string { + base := strings.TrimRight(s.cfg.PublicURL, "/") + path := "/api/exports/chats/" + rawToken + if base == "" { + return path + } + return base + path +} + +func (s *Server) decorateChatExportToken(tok *models.ChatExportToken) { + if tok == nil { + return + } + enc := tok.Token + if enc == "" { + return + } + raw, err := store.DecryptInviteToken(s.cfg.JWTSecret, enc) + if err != nil { + tok.Token = "" + return + } + tok.Token = raw + tok.URL = s.chatExportDownloadURL(raw) + tok.Active = tok.RevokedAt == nil && tok.UsedAt == nil && tok.ExpiresAt.After(time.Now().UTC()) +} + +func safeExportFilename(name string) string { + base := filepath.Base(strings.TrimSpace(name)) + if base == "" || base == "." || base == ".." { + base = "file" + } + base = unsafeFilenameChars.ReplaceAllString(base, "_") + base = strings.Trim(base, "._") + if base == "" { + base = "file" + } + if len(base) > 120 { + ext := filepath.Ext(base) + base = base[:120-len(ext)] + ext + } + return base +} + +func conversationExportKey(studentID, mentorID int64) string { + return fmt.Sprintf("%d-%d", studentID, mentorID) +} + +func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []zipAttachmentFile, error) { + pairs, err := s.store.ListChatExportPairs(r.Context()) + if err != nil { + return nil, nil, err + } + out := &chatExportPayload{ + Version: chatExportPayloadVersion, + Schema: chatExportSchema, + ExportedAt: time.Now().UTC(), + Format: "zip", + } + var files []zipAttachmentFile + const pageSize int64 = 2000 + for _, p := range pairs { + convKey := conversationExportKey(p.StudentID, p.MentorID) + conv := chatExportConversation{ + StudentID: p.StudentID, + StudentName: p.StudentName, + MentorID: p.MentorID, + MentorName: p.MentorName, + Participants: chatExportParticipants{ + Student: chatExportParticipant{ + ID: p.StudentID, Name: p.StudentName, Role: string(models.RoleStudent), + }, + Mentor: chatExportParticipant{ + ID: p.MentorID, Name: p.MentorName, Role: string(models.RoleMentor), + }, + }, + } + var before int64 + for { + msgs, err := s.store.ListChatMessages(r.Context(), p.StudentID, p.MentorID, before, pageSize) + if err != nil { + return nil, nil, err + } + if len(msgs) == 0 { + break + } + batch := make([]chatExportMessage, 0, len(msgs)) + for _, m := range msgs { + item := chatExportMessage{ + ID: m.ID, + Body: m.Body, + SenderRole: string(m.SenderRole), + CreatedAt: m.CreatedAt, + ReadAt: m.ReadAt, + ReplyTo: m.ReplyTo, + Pinned: m.PinnedAt != nil, + PinnedAt: m.PinnedAt, + } + if m.Attachment != nil { + att := &chatExportAttachment{ + Type: m.Attachment.Type, + Name: m.Attachment.Name, + Size: m.Attachment.Size, + } + key := mediaKeyFromURL(m.Attachment.URL) + if key == "" { + att.Missing = true + } else { + rel := filepath.ToSlash(filepath.Join( + "attachments", + convKey, + fmt.Sprintf("%d-%s", m.ID, safeExportFilename(m.Attachment.Name)), + )) + rc, _, _, err := s.storage.Open(key) + if err != nil { + att.Missing = true + } else { + _ = rc.Close() + att.Path = rel + files = append(files, zipAttachmentFile{Path: rel, Key: key}) + } + } + item.Attachment = att + } + batch = append(batch, item) + } + conv.Messages = append(batch, conv.Messages...) + if int64(len(msgs)) < pageSize { + break + } + before = msgs[0].ID + } + if conv.Messages == nil { + conv.Messages = []chatExportMessage{} + } + conv.MessageCount = len(conv.Messages) + out.Conversations = append(out.Conversations, conv) + } + if out.Conversations == nil { + out.Conversations = []chatExportConversation{} + } + out.ConversationN = len(out.Conversations) + return out, files, nil +} + +func (s *Server) writeChatExportZip(w http.ResponseWriter, payload *chatExportPayload, files []zipAttachmentFile) error { + tmp, err := os.CreateTemp("", "pargar-chat-export-*.zip") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + + zw := zip.NewWriter(tmp) + manifestBytes, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + mw, err := zw.Create("manifest.json") + if err != nil { + return err + } + if _, err := mw.Write(manifestBytes); err != nil { + return err + } + + seen := map[string]bool{} + for _, f := range files { + if f.Path == "" || f.Key == "" || seen[f.Path] { + continue + } + seen[f.Path] = true + rc, _, _, err := s.storage.Open(f.Key) + if err != nil { + continue + } + hdr := &zip.FileHeader{ + Name: f.Path, + Method: zip.Deflate, + Modified: time.Now().UTC(), + } + aw, err := zw.CreateHeader(hdr) + if err != nil { + _ = rc.Close() + return err + } + _, copyErr := io.Copy(aw, rc) + _ = rc.Close() + if copyErr != nil { + return copyErr + } + } + if err := zw.Close(); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return err + } + info, err := tmp.Stat() + if err != nil { + return err + } + + filename := fmt.Sprintf("chat-export-%s.zip", time.Now().UTC().Format("2006-01-02")) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, tmp) + return err +} + +func (s *Server) handleAdminChatExport(w http.ResponseWriter, r *http.Request) { + out, files, err := s.buildProjectChatExport(r) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return + } + observability.Activity("chat.export", "actor_id", currentUser(r).ID, "conversations", len(out.Conversations), "files", len(files)) + if err := s.writeChatExportZip(w, out, files); err != nil { + observability.L().Error("chat.export_zip", "error", err) + } +} + +func (s *Server) handleAdminChatExportStats(w http.ResponseWriter, r *http.Request) { + stats, err := s.store.ChatExportStats(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "خواندن آمار آرشیو گفتگوها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"stats": stats}) +} + +func (s *Server) handleAdminChatExportRestore(w http.ResponseWriter, r *http.Request) { + payload, zipFiles, err := s.readChatExportRestore(r) + if err != nil { + writeValidationErr(w, err.Error()) + return + } + if payload.Version != 0 && payload.Version != 2 && payload.Version != 3 && payload.Version != chatExportPayloadVersion { + writeErr(w, http.StatusBadRequest, "نسخهٔ خروجی گفتگوها پشتیبانی نمی‌شود") + return + } + imported := 0 + for _, conv := range payload.Conversations { + studentID := conv.StudentID + mentorID := conv.MentorID + if studentID <= 0 && conv.Participants.Student.ID > 0 { + studentID = conv.Participants.Student.ID + } + if mentorID <= 0 && conv.Participants.Mentor.ID > 0 { + mentorID = conv.Participants.Mentor.ID + } + if studentID <= 0 || mentorID <= 0 { + continue + } + student, err := s.store.GetUserByID(r.Context(), studentID) + if err != nil || student == nil { + continue + } + mentor, err := s.store.GetUserByID(r.Context(), mentorID) + if err != nil || mentor == nil { + continue + } + for _, m := range conv.Messages { + role := models.Role(m.SenderRole) + if role != student.Role && role != mentor.Role { + if student.Role == models.RoleStudent { + role = student.Role + } else { + role = mentor.Role + } + } + created := m.CreatedAt + if created.IsZero() { + created = time.Now().UTC() + } + var att *models.Attachment + if m.Attachment != nil { + att = s.materializeRestoredAttachment(studentID, m.Attachment, zipFiles) + } + if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, att, created, m.Pinned); err != nil { + continue + } + imported++ + } + } + observability.Activity("chat.export_restored", "actor_id", currentUser(r).ID, "imported", imported) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported}) +} + +func (s *Server) readChatExportRestore(r *http.Request) (*chatExportPayload, map[string][]byte, error) { + ct := strings.ToLower(r.Header.Get("Content-Type")) + const maxRestore = 64 << 20 + r.Body = http.MaxBytesReader(nil, r.Body, maxRestore) + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, nil, errors.New("خواندن فایل ممکن نشد") + } + if len(data) == 0 { + return nil, nil, errors.New("فایل خالی است") + } + + // ZIP magic + if len(data) >= 4 && data[0] == 'P' && data[1] == 'K' { + return s.parseChatExportZip(data) + } + if strings.Contains(ct, "zip") { + return s.parseChatExportZip(data) + } + + var payload chatExportPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, nil, errors.New("فایل خروجی گفتگوها نامعتبر است") + } + return &payload, nil, nil +} + +func (s *Server) parseChatExportZip(data []byte) (*chatExportPayload, map[string][]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, nil, errors.New("آرشیو ZIP نامعتبر است") + } + files := map[string][]byte{} + var manifest []byte + for _, f := range zr.File { + name := filepath.ToSlash(f.Name) + if strings.HasPrefix(name, "__MACOSX/") || strings.HasSuffix(name, "/") { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + body, err := io.ReadAll(io.LimitReader(rc, 32<<20)) + _ = rc.Close() + if err != nil { + continue + } + if name == "manifest.json" { + manifest = body + continue + } + if strings.HasPrefix(name, "attachments/") { + files[name] = body + } + } + if len(manifest) == 0 { + return nil, nil, errors.New("manifest.json در آرشیو یافت نشد") + } + var payload chatExportPayload + if err := json.Unmarshal(manifest, &payload); err != nil { + return nil, nil, errors.New("manifest.json نامعتبر است") + } + return &payload, files, nil +} + +type bytesFile struct { + *bytes.Reader +} + +func (b bytesFile) Close() error { return nil } + +func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment { + if src == nil { + return nil + } + name := safeExportFilename(src.Name) + if name == "" { + name = "file" + } + attType := src.Type + if attType == "" { + attType = attachmentType("", filepath.Ext(name)) + } + + // Prefer embedded zip bytes. + if src.Path != "" && zipFiles != nil { + path := filepath.ToSlash(src.Path) + if raw, ok := zipFiles[path]; ok && len(raw) > 0 { + key := fmt.Sprintf("chats/%d-%d%s", ownerID, time.Now().UnixNano(), filepath.Ext(name)) + url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw))) + if err == nil { + return &models.Attachment{ + Type: attType, + URL: s.signExistingMediaURL(url), + Name: name, + Size: int64(len(raw)), + } + } + } + } + + // Legacy JSON: keep original URL reference if present. + if src.URL != "" { + return &models.Attachment{ + Type: attType, + URL: src.URL, + Name: name, + Size: src.Size, + } + } + return nil +} + +type createChatExportTokenRequest struct { + ExpiresInHours int `json:"expiresInHours"` + Label string `json:"label"` +} + +func (s *Server) handleAdminCreateChatExportToken(w http.ResponseWriter, r *http.Request) { + var req createChatExportTokenRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + hours := req.ExpiresInHours + if hours != 1 && hours != 24 { + writeErr(w, http.StatusBadRequest, "مهلت باید ۱ یا ۲۴ ساعت باشد") + return + } + raw, err := store.NewInviteToken() + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت توکن ممکن نشد") + return + } + enc, err := store.EncryptInviteToken(s.cfg.JWTSecret, raw) + if err != nil { + writeErr(w, http.StatusInternalServerError, "رمزنگاری توکن ممکن نشد") + return + } + expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour) + admin := currentUser(r) + tok, err := s.store.CreateChatExportToken( + r.Context(), + store.HashInviteToken(raw), + enc, + req.Label, + expiresAt, + admin.ID, + ) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره لینک دانلود ممکن نشد") + return + } + tok.Token = raw + tok.URL = s.chatExportDownloadURL(raw) + tok.Active = true + observability.Activity("chat.export_token_create", "token_id", tok.ID, "admin_id", admin.ID, "hours", hours) + writeJSON(w, http.StatusCreated, map[string]any{"token": tok}) +} + +func (s *Server) handleAdminListChatExportTokens(w http.ResponseWriter, r *http.Request) { + tokens, err := s.store.ListChatExportTokens(r.Context(), 100) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های دانلود ممکن نشد") + return + } + for i := range tokens { + s.decorateChatExportToken(&tokens[i]) + } + writeJSON(w, http.StatusOK, map[string]any{"items": tokens}) +} + +func (s *Server) handleAdminRevokeChatExportToken(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + tok, err := s.store.RevokeChatExportToken(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrChatExportTokenNotFound) { + writeErr(w, http.StatusNotFound, "لینک دانلود یافت نشد") + return + } + if errors.Is(err, store.ErrChatExportTokenInvalid) { + s.decorateChatExportToken(tok) + writeJSON(w, http.StatusOK, map[string]any{"token": tok, "alreadyInactive": true}) + return + } + writeErr(w, http.StatusInternalServerError, "لغو لینک ممکن نشد") + return + } + s.decorateChatExportToken(tok) + observability.Activity("chat.export_token_revoke", "token_id", tok.ID, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"token": tok}) +} + +func (s *Server) handleExternalChatExport(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(r.PathValue("token")) + if raw == "" { + writeErr(w, http.StatusUnauthorized, "توکن لازم است") + return + } + tok, err := s.store.GetChatExportTokenByHash(r.Context(), store.HashInviteToken(raw)) + if err != nil { + if errors.Is(err, store.ErrChatExportTokenNotFound) { + writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است") + return + } + writeErr(w, http.StatusInternalServerError, "بررسی توکن ممکن نشد") + return + } + if err := store.ValidateChatExportToken(tok, time.Now().UTC()); err != nil { + writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است") + return + } + // Consume before building the ZIP so a second concurrent hit cannot reuse the link. + if _, err := s.store.ConsumeChatExportToken(r.Context(), tok.ID); err != nil { + writeErr(w, http.StatusUnauthorized, "لینک نامعتبر یا منقضی است") + return + } + out, files, err := s.buildProjectChatExport(r) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد") + return + } + observability.Activity("chat.export_external", "token_id", tok.ID, "conversations", len(out.Conversations), "files", len(files), "ip", clientAddress(r)) + if err := s.writeChatExportZip(w, out, files); err != nil { + observability.L().Error("chat.export_zip", "error", err) + } +} diff --git a/backend/internal/api/chat_export_test.go b/backend/internal/api/chat_export_test.go new file mode 100644 index 0000000..5052efe --- /dev/null +++ b/backend/internal/api/chat_export_test.go @@ -0,0 +1,398 @@ +package api_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + "time" +) + +func (e *testEnv) doRaw(t *testing.T, method, path, token string, body any) (int, string, []byte) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req, _ := http.NewRequest(method, e.server.URL+path, &buf) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, resp.Header.Get("Content-Type"), raw +} + +func readZipManifest(t *testing.T, raw []byte) (map[string]any, *zip.Reader) { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatalf("zip open: %v", err) + } + for _, f := range zr.File { + if f.Name != "manifest.json" { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatalf("manifest open: %v", err) + } + defer rc.Close() + var out map[string]any + if err := json.NewDecoder(rc).Decode(&out); err != nil { + t.Fatalf("manifest json: %v", err) + } + return out, zr + } + t.Fatal("manifest.json missing") + return nil, nil +} + +func TestAdminChatExportAccess(t *testing.T) { + e := setup(t) + student, studentID := register(t, e, "Export Student", "export-student@test.dev") + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Export", "email": "mentor-export@test.dev", "username": "mentorexport", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + mentor := login(t, e, "mentor-export@test.dev") + + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{"body": "سلام آرشیو"}) + if code != http.StatusCreated { + t.Fatalf("send: %d %v", code, body) + } + _ = studentID + + code, _ = e.do(t, "GET", "/api/admin/chat-export", student, nil) + if code != http.StatusForbidden { + t.Fatalf("student export: want 403 got %d", code) + } + + code, _ = e.do(t, "GET", "/api/admin/chat-export", mentor, nil) + if code != http.StatusForbidden { + t.Fatalf("mentor export: want 403 got %d", code) + } + + code, ct, raw := e.doRaw(t, "GET", "/api/admin/chat-export", admin, nil) + if code != http.StatusOK { + t.Fatalf("admin export: %d %s", code, string(raw)) + } + if !strings.Contains(ct, "application/zip") { + t.Fatalf("content-type: %s", ct) + } + dump, zr := readZipManifest(t, raw) + if int(dump["version"].(float64)) != 4 { + t.Fatalf("version: %v", dump["version"]) + } + if dump["schema"] != "pargar.chat-export" { + t.Fatalf("schema: %v", dump["schema"]) + } + if dump["format"] != "zip" { + t.Fatalf("format: %v", dump["format"]) + } + convs, _ := dump["conversations"].([]any) + if len(convs) == 0 { + t.Fatal("expected conversations in project export") + } + first := convs[0].(map[string]any) + if first["participants"] == nil { + t.Fatal("expected participants object") + } + msgs, _ := first["messages"].([]any) + if len(msgs) == 0 { + t.Fatal("expected messages") + } + msg0 := msgs[0].(map[string]any) + if msg0["id"] == nil || msg0["createdAt"] == nil || msg0["senderRole"] == nil { + t.Fatalf("message missing structured fields: %v", msg0) + } + _ = zr + + // Restore via ZIP bytes + req, _ := http.NewRequest("POST", e.server.URL+"/api/admin/chat-export/restore", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+admin) + req.Header.Set("Content-Type", "application/zip") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("restore request: %v", err) + } + defer resp.Body.Close() + var restored map[string]any + _ = json.NewDecoder(resp.Body).Decode(&restored) + if resp.StatusCode != http.StatusOK { + t.Fatalf("restore: %d %v", resp.StatusCode, restored) + } + if int(restored["imported"].(float64)) < 1 { + t.Fatalf("expected imported >= 1, got %v", restored["imported"]) + } + + code, statsBody := e.do(t, "GET", "/api/admin/chat-export/stats", admin, nil) + if code != http.StatusOK { + t.Fatalf("stats: %d %v", code, statsBody) + } + stats, _ := statsBody["stats"].(map[string]any) + if stats == nil { + t.Fatalf("expected stats object: %v", statsBody) + } + if int(stats["conversationCount"].(float64)) < 1 { + t.Fatalf("conversationCount: %v", stats["conversationCount"]) + } + if int(stats["messageCount"].(float64)) < 1 { + t.Fatalf("messageCount: %v", stats["messageCount"]) + } + if int(stats["estimatedBytes"].(float64)) < 1 { + t.Fatalf("estimatedBytes: %v", stats["estimatedBytes"]) + } + code, _ = e.do(t, "GET", "/api/admin/chat-export/stats", student, nil) + if code != http.StatusForbidden { + t.Fatalf("student stats: want 403 got %d", code) + } + + code, _ = e.do(t, "GET", "/api/chats/backup", student, nil) + if code != http.StatusNotFound && code != http.StatusMethodNotAllowed { + t.Fatalf("legacy backup should be unavailable, got %d", code) + } + code, _ = e.do(t, "GET", "/api/admin/chat-dump", admin, nil) + if code != http.StatusNotFound && code != http.StatusMethodNotAllowed { + t.Fatalf("legacy chat-dump path should be gone, got %d", code) + } +} + +func TestChatExportZipIncludesAttachment(t *testing.T) { + e := setup(t) + student, studentID := register(t, e, "Att Export Student", "att-export@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Att", "email": "mentor-att@test.dev", "username": "mentoratt", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + + mediaName := "hello-export.txt" + mediaBytes := []byte("attachment-bytes-for-export") + code, up := uploadChatFile(t, e, student, mediaName, mediaBytes) + if code != http.StatusCreated { + t.Fatalf("upload: %d %v", code, up) + } + att, _ := up["attachment"].(map[string]any) + if att == nil || att["url"] == "" { + t.Fatalf("missing attachment url: %v", up) + } + + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{ + "body": "با پیوست", + "attachment": att, + }) + if code != http.StatusCreated { + t.Fatalf("send with att: %d %v", code, body) + } + _ = studentID + + code, ct, raw := e.doRaw(t, "GET", "/api/admin/chat-export", admin, nil) + if code != http.StatusOK { + t.Fatalf("export: %d %s", code, string(raw)) + } + if !strings.Contains(ct, "zip") { + t.Fatalf("ct: %s", ct) + } + dump, zr := readZipManifest(t, raw) + convs := dump["conversations"].([]any) + foundPath := "" + for _, c := range convs { + cm := c.(map[string]any) + for _, m := range cm["messages"].([]any) { + mm := m.(map[string]any) + a, _ := mm["attachment"].(map[string]any) + if a == nil { + continue + } + if p, _ := a["path"].(string); p != "" { + foundPath = p + } + if a["missing"] == true { + t.Fatalf("attachment marked missing: %v", a) + } + } + } + if foundPath == "" { + t.Fatal("expected attachment path in manifest") + } + var foundInZip bool + for _, f := range zr.File { + if f.Name == foundPath { + foundInZip = true + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(rc) + _ = rc.Close() + if !bytes.Equal(got, mediaBytes) { + t.Fatalf("attachment bytes mismatch: %q", got) + } + } + } + if !foundInZip { + t.Fatalf("zip missing %s", foundPath) + } +} + +func TestChatExportTokenDownload(t *testing.T) { + e := setup(t) + student, _ := register(t, e, "Tok Export Student", "tok-export@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Tok", "email": "mentor-tok@test.dev", "username": "mentortok", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{"body": "token export msg"}) + if code != http.StatusCreated { + t.Fatalf("send: %d %v", code, body) + } + + code, _ = e.do(t, "POST", "/api/admin/chat-export/tokens", student, map[string]any{"expiresInHours": 1}) + if code != http.StatusForbidden { + t.Fatalf("student create token: want 403 got %d", code) + } + + code, created := e.do(t, "POST", "/api/admin/chat-export/tokens", admin, map[string]any{ + "expiresInHours": 1, + "label": "تست لینک", + }) + if code != http.StatusCreated { + t.Fatalf("create token: %d %v", code, created) + } + tokObj := created["token"].(map[string]any) + rawToken, _ := tokObj["token"].(string) + url, _ := tokObj["url"].(string) + tokenID := int64(tokObj["id"].(float64)) + if rawToken == "" || url == "" { + t.Fatalf("missing token/url: %v", tokObj) + } + if !strings.Contains(url, "/api/exports/chats/") { + t.Fatalf("unexpected url shape: %s", url) + } + + code, ct, raw := e.doRaw(t, "GET", "/api/exports/chats/"+rawToken, "", nil) + if code != http.StatusOK { + t.Fatalf("token download: %d %s", code, string(raw)) + } + if !strings.Contains(ct, "zip") { + t.Fatalf("content-type: %s", ct) + } + dump, _ := readZipManifest(t, raw) + convs, _ := dump["conversations"].([]any) + if len(convs) == 0 { + t.Fatal("expected conversations from token download") + } + + code, _, _ = e.doRaw(t, "GET", "/api/exports/chats/"+rawToken, "", nil) + if code != http.StatusUnauthorized { + t.Fatalf("second download should be single-use 401: got %d", code) + } + + code, listed := e.do(t, "GET", "/api/admin/chat-export/tokens", admin, nil) + if code != http.StatusOK { + t.Fatalf("list tokens: %d %v", code, listed) + } + items, _ := listed["items"].([]any) + if len(items) == 0 { + t.Fatal("expected listed tokens") + } + + code, revoked := e.do(t, "POST", "/api/admin/chat-export/tokens/"+itoa(tokenID)+"/revoke", admin, nil) + if code != http.StatusOK { + t.Fatalf("revoke: %d %v", code, revoked) + } + code, _, _ = e.doRaw(t, "GET", "/api/exports/chats/"+rawToken, "", nil) + if code != http.StatusUnauthorized { + t.Fatalf("revoked: want 401 got %d", code) + } +} + +func TestChatExportTokenExpired(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, created := e.do(t, "POST", "/api/admin/chat-export/tokens", admin, map[string]any{"expiresInHours": 1}) + if code != http.StatusCreated { + t.Fatalf("create: %d %v", code, created) + } + tokObj := created["token"].(map[string]any) + rawToken := tokObj["token"].(string) + id := int64(tokObj["id"].(float64)) + + _, err := e.pool.Exec(context.Background(), ` + UPDATE chat_export_tokens SET expires_at = $1 WHERE id = $2`, + time.Now().UTC().Add(-time.Minute), id) + if err != nil { + t.Fatalf("force expire: %v", err) + } + + code, _, _ = e.doRaw(t, "GET", "/api/exports/chats/"+rawToken, "", nil) + if code != http.StatusUnauthorized { + t.Fatalf("expired: want 401 got %d", code) + } + + code, _, _ = e.doRaw(t, "GET", "/api/exports/chats/not-a-real-token", "", nil) + if code != http.StatusUnauthorized { + t.Fatalf("bogus token: want 401 got %d", code) + } + + code, _ = e.do(t, "POST", "/api/admin/chat-export/tokens", admin, map[string]any{"expiresInHours": 12}) + if code != http.StatusBadRequest { + t.Fatalf("invalid hours: want 400 got %d", code) + } +} + +// --- helpers for multipart chat upload in tests --- + +func uploadChatFile(t *testing.T, e *testEnv, token, filename string, content []byte) (int, map[string]any) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(content); err != nil { + t.Fatal(err) + } + _ = mw.Close() + req, _ := http.NewRequest("POST", e.server.URL+"/api/chats/upload", &buf) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", mw.FormDataContentType()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} diff --git a/backend/internal/api/community.go b/backend/internal/api/community.go new file mode 100644 index 0000000..f9f981e --- /dev/null +++ b/backend/internal/api/community.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "pargar/backend/internal/models" +) + +func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + donationEnabled := s.cfg.DonationEnabled + if v, err := s.store.GetSetting(ctx, "donation_enabled"); err == nil { + donationEnabled = v == "true" || v == "1" + } + note := s.cfg.DonationNote + if v, err := s.store.GetSetting(ctx, "donation_note"); err == nil && strings.TrimSpace(v) != "" { + note = strings.TrimSpace(v) + } + if strings.TrimSpace(note) == "" { + note = "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود." + } + + var contactID any + if u, err := s.Authenticate(r); err == nil && u != nil { + if id := s.donationContactID(ctx, u); id > 0 { + contactID = id + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "donation": map[string]any{ + "enabled": donationEnabled, + "note": note, + "mode": "chat", + "contactId": contactID, + "draft": "سلام؛ دربارهٔ حمایت مالی / دونیت هماهنگ کنیم.", + }, + }) +} + +func (s *Server) donationContactID(ctx context.Context, actor *models.User) int64 { + staff, err := s.store.ListMentors(ctx) + if err != nil { + return 0 + } + var adminID, mentorID int64 + for _, m := range staff { + if m.ID == actor.ID { + continue + } + if m.Role == models.RoleAdmin && adminID == 0 { + adminID = m.ID + } + if m.Role == models.RoleMentor && mentorID == 0 { + mentorID = m.ID + } + } + if adminID != 0 { + return adminID + } + return mentorID +} diff --git a/backend/internal/api/community_test.go b/backend/internal/api/community_test.go new file mode 100644 index 0000000..2f96757 --- /dev/null +++ b/backend/internal/api/community_test.go @@ -0,0 +1,31 @@ +package api_test + +import ( + "net/http" + "testing" +) + +func TestCommunityEndpointPublic(t *testing.T) { + e := setup(t) + code, body := e.do(t, "GET", "/api/community", "", nil) + if code != http.StatusOK { + t.Fatalf("community: %d %v", code, body) + } + don, ok := body["donation"].(map[string]any) + if !ok { + t.Fatalf("missing donation: %v", body) + } + if _, ok := don["enabled"]; !ok { + t.Fatalf("missing donation.enabled: %v", don) + } + if don["mode"] != "chat" { + t.Fatalf("donation.mode want chat, got %v", don["mode"]) + } + if _, hasCard := don["cardNumber"]; hasCard { + t.Fatal("public card number must not be exposed") + } + // Telegram channel surface was removed from the product. + if _, hasChannel := body["channel"]; hasChannel { + t.Fatal("legacy channel field must not be exposed") + } +} diff --git a/backend/internal/api/errors.go b/backend/internal/api/errors.go new file mode 100644 index 0000000..cf7c81a --- /dev/null +++ b/backend/internal/api/errors.go @@ -0,0 +1,103 @@ +package api + +import ( + "net/http" + + "pargar/backend/internal/observability" +) + +// Stable machine-readable error codes for clients. +const ( + CodeBadRequest = "bad_request" + CodeUnauthorized = "unauthorized" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeConflict = "conflict" + CodeLocked = "account_locked" + CodePayloadTooLarge = "payload_too_large" + CodeRateLimited = "rate_limited" + CodeValidation = "validation_error" + CodeInternal = "internal_error" +) + +type apiError struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` + RequestID string `json:"requestId,omitempty"` +} + +func requestIDFromWriter(w http.ResponseWriter) string { + if w == nil { + return "" + } + return w.Header().Get("X-Request-ID") +} + +func defaultCodeForStatus(status int) string { + switch status { + case http.StatusBadRequest: + return CodeBadRequest + case http.StatusUnauthorized: + return CodeUnauthorized + case http.StatusForbidden: + return CodeForbidden + case http.StatusNotFound: + return CodeNotFound + case http.StatusConflict: + return CodeConflict + case http.StatusLocked: + return CodeLocked + case http.StatusRequestEntityTooLarge: + return CodePayloadTooLarge + case http.StatusTooManyRequests: + return CodeRateLimited + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable: + return CodeInternal + default: + if status >= 500 { + return CodeInternal + } + if status >= 400 { + return CodeBadRequest + } + return "" + } +} + +// writeErr responds with a Persian user message and a stable code derived from status. +func writeErr(w http.ResponseWriter, status int, msg string) { + writeErrCode(w, status, defaultCodeForStatus(status), msg) +} + +// writeErrCode responds with an explicit machine-readable code. +func writeErrCode(w http.ResponseWriter, status int, code, msg string) { + if code == "" { + code = defaultCodeForStatus(status) + } + if msg == "" { + msg = "خطایی رخ داد؛ دوباره تلاش کنید" + } + writeJSON(w, status, apiError{ + Error: msg, + Code: code, + RequestID: requestIDFromWriter(w), + }) +} + +// writeInternalErr logs the real error server-side and returns a safe 500 to the client. +func writeInternalErr(w http.ResponseWriter, op string, err error, userMsg string) { + if userMsg == "" { + userMsg = "مشکل موقتی سرور؛ کمی بعد دوباره تلاش کنید." + } + attrs := []any{"op", op, "request_id", requestIDFromWriter(w)} + if err != nil { + attrs = append(attrs, "error", err) + } + observability.L().Error("api.error", attrs...) + writeErrCode(w, http.StatusInternalServerError, CodeInternal, userMsg) +} + +// writeValidationErr is a 400 with validation_error code. +func writeValidationErr(w http.ResponseWriter, msg string) { + writeErrCode(w, http.StatusBadRequest, CodeValidation, msg) +} diff --git a/backend/internal/api/errors_test.go b/backend/internal/api/errors_test.go new file mode 100644 index 0000000..481aa52 --- /dev/null +++ b/backend/internal/api/errors_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteErrIncludesCodeAndRequestID(t *testing.T) { + rec := httptest.NewRecorder() + rec.Header().Set("X-Request-ID", "abc123deadbeef") + writeErr(rec, http.StatusBadRequest, "داده نامعتبر است") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: %d", rec.Code) + } + var body apiError + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Error != "داده نامعتبر است" { + t.Fatalf("error: %q", body.Error) + } + if body.Code != CodeBadRequest { + t.Fatalf("code: %q", body.Code) + } + if body.RequestID != "abc123deadbeef" { + t.Fatalf("requestId: %q", body.RequestID) + } +} + +func TestWriteErrCodeLocked(t *testing.T) { + rec := httptest.NewRecorder() + writeErrCode(rec, http.StatusLocked, CodeLocked, "حساب محدود شده است") + var body apiError + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Code != CodeLocked { + t.Fatalf("code: %q", body.Code) + } +} + +func TestWriteInternalErrHidesCause(t *testing.T) { + rec := httptest.NewRecorder() + writeInternalErr(rec, "test.op", errors.New("secret db detail"), "عملیات ممکن نشد") + var body apiError + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Code != CodeInternal { + t.Fatalf("code: %q", body.Code) + } + if body.Error != "عملیات ممکن نشد" { + t.Fatalf("error: %q", body.Error) + } + if strings.Contains(rec.Body.String(), "secret") { + t.Fatalf("leaked internal detail: %s", rec.Body.String()) + } +} diff --git a/backend/internal/api/events.go b/backend/internal/api/events.go new file mode 100644 index 0000000..2b08609 --- /dev/null +++ b/backend/internal/api/events.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "pargar/backend/internal/models" +) + +func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + events, err := s.store.ListUpcomingEvents(r.Context(), u.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری رویدادها ممکن نشد") + return + } + if events == nil { + events = []models.Event{} + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} + +func (s *Server) handleGetEvent(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + e, err := s.store.GetEvent(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "رویداد پیدا نشد") + return + } + e.Rsvped = s.rsvped(r, e.ID, u.ID) + writeJSON(w, http.StatusOK, map[string]any{"event": e}) +} + +func (s *Server) rsvped(r *http.Request, eventID, userID int64) bool { + events, err := s.store.ListUpcomingEvents(r.Context(), userID) + if err != nil { + return false + } + for _, e := range events { + if e.ID == eventID { + return e.Rsvped + } + } + return false +} + +func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + if err := s.store.Rsvp(r.Context(), id, u.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت‌نام در رویداد ممکن نشد") + return + } + e, err := s.store.GetEvent(r.Context(), id) + if err == nil { + go func() { + _ = s.notify.Notify(context.Background(), u.ID, "event", "rsvp", + "ثبت‌نام رویداد تأیید شد", "شما در رویداد «"+e.Title+"» ثبت‌نام کرده‌اید. هنگام شروع به شما یادآوری می‌کنیم.", "/unwrap?tab=events", nil) + }() + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "rsvped": true}) +} + +func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + if err := s.store.Unrsvp(r.Context(), id, u.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "لغو ثبت‌نام رویداد ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "rsvped": false}) +} + +func (s *Server) handleEventICS(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + e, err := s.store.GetEvent(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "رویداد پیدا نشد") + return + } + _ = u // any authenticated user may download + ics := buildICS(e, s.cfg.PublicURL) + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=event-"+fmt.Sprintf("%d", e.ID)+".ics") + _, _ = w.Write([]byte(ics)) +} + +func buildICS(e *models.Event, origin string) string { + f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") } + return fmt.Sprintf(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Pargar//Events//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:%d@pargar +DTSTAMP:%s +DTSTART:%s +DTEND:%s +SUMMARY:%s +DESCRIPTION:%s +LOCATION:%s +END:VEVENT +END:VCALENDAR +`, e.ID, f(time.Now()), f(e.StartsAt), f(e.EndsAt), + escapeICS(e.Title), escapeICS(e.Description), escapeICS(e.ExternalURL)) +} + +func escapeICS(v string) string { + out := "" + for _, r := range v { + switch r { + case '\\': + out += "\\\\" + case ';': + out += "\\;" + case ',': + out += "\\," + case '\n': + out += "\\n" + default: + out += string(r) + } + } + return out +} diff --git a/backend/internal/api/events_test.go b/backend/internal/api/events_test.go new file mode 100644 index 0000000..620c26a --- /dev/null +++ b/backend/internal/api/events_test.go @@ -0,0 +1,101 @@ +package api_test + +import ( + "testing" + "time" +) + +func TestAdminEventCreateUpdateIsActive(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + starts := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second) + ends := starts.Add(time.Hour) + status, body := e.do(t, "POST", "/api/admin/events", token, map[string]any{ + "title": "جلسه تست", + "description": "توضیح", + "eventType": "workshop", + "externalUrl": "https://example.com/meet", + "startsAt": starts.Format(time.RFC3339), + "endsAt": ends.Format(time.RFC3339), + "isActive": false, + }) + if status != 201 { + t.Fatalf("create status=%d body=%v", status, body) + } + id, _ := body["id"].(float64) + if id == 0 { + t.Fatalf("missing id: %v", body) + } + + status, list := e.do(t, "GET", "/api/admin/events", token, nil) + if status != 200 { + t.Fatalf("list status=%d", status) + } + events, _ := list["events"].([]any) + found := false + for _, raw := range events { + ev, _ := raw.(map[string]any) + if ev["id"] == id { + found = true + if ev["isActive"] != false { + t.Fatalf("expected isActive=false, got %#v", ev["isActive"]) + } + } + } + if !found { + t.Fatal("created event not in list") + } + + status, _ = e.do(t, "PUT", "/api/admin/events/"+itoa(int64(id)), token, map[string]any{ + "title": "جلسه تست", + "description": "توضیح", + "eventType": "workshop", + "externalUrl": "https://example.com/meet", + "startsAt": starts.Format(time.RFC3339), + "endsAt": ends.Format(time.RFC3339), + "isActive": true, + }) + if status != 200 { + t.Fatalf("update status=%d", status) + } + + status, bad := e.do(t, "PUT", "/api/admin/events/"+itoa(int64(id)), token, map[string]any{ + "title": "بازه بد", + "eventType": "meet", + "startsAt": ends.Format(time.RFC3339), + "endsAt": starts.Format(time.RFC3339), + "isActive": true, + }) + if status != 400 { + t.Fatalf("expected 400 for inverted range, got %d body=%v", status, bad) + } +} + +func TestAdminChallengeCRUD(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + starts := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) + ends := starts.Add(30 * 24 * time.Hour) + status, body := e.do(t, "POST", "/api/admin/challenges", token, map[string]any{ + "title": "چالش ماه", + "description": "۵۰۰ امتیاز", + "targetXp": 500, + "startsAt": starts.Format(time.RFC3339), + "endsAt": ends.Format(time.RFC3339), + "isActive": true, + }) + if status != 201 { + t.Fatalf("create challenge status=%d body=%v", status, body) + } + + status, mine := e.do(t, "GET", "/api/challenges", token, nil) + if status != 200 { + t.Fatalf("list challenges status=%d", status) + } + chs, _ := mine["challenges"].([]any) + if len(chs) == 0 { + t.Fatal("expected active challenge for student/admin") + } +} diff --git a/backend/internal/api/flow_test.go b/backend/internal/api/flow_test.go new file mode 100644 index 0000000..2405178 --- /dev/null +++ b/backend/internal/api/flow_test.go @@ -0,0 +1,602 @@ +package api_test + +import ( + "fmt" + "net/http" + "testing" +) + +func TestAuthRoundTrip(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Tester", "tester@test.dev") + if uid <= 0 { + t.Fatal("expected positive id") + } + code, body := e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + u := body["user"].(map[string]any) + if u["email"] != "tester@test.dev" { + t.Fatalf("wrong user %v", u["email"]) + } + if u["role"] != "student" { + t.Fatalf("role must default to student, got %v", u["role"]) + } + // public self-registration is disabled; duplicate email rejected via admin path + code, _ = e.do(t, "POST", "/api/auth/register", "", map[string]any{ + "name": "Tester", "email": "tester@test.dev", "password": "password123", + }) + if code == http.StatusCreated { + t.Fatal("public registration must be disabled") + } + admin := login(t, e, bootstrapAdminEmail) + code, _ = e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Tester", "email": "tester@test.dev", "username": "tester", "password": "password123", "role": "student", + "securityQuestion": "رنگ مورد علاقه؟", "securityAnswer": "آبی", + }) + if code != http.StatusConflict { + t.Fatalf("expected 409, got %d", code) + } +} + +func TestTreeGatekeeping(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Tree", "tree@test.dev") + code, body := e.do(t, "GET", "/api/tree", token, nil) + if code != http.StatusOK { + t.Fatalf("tree: %d", code) + } + chapters := body["chapters"].([]any) + if len(chapters) != 3 { + t.Fatalf("expected 3 chapters, got %d", len(chapters)) + } + firstChapter := chapters[0].(map[string]any) + lessons := firstChapter["lessons"].([]any) + first := lessons[0].(map[string]any) + if first["locked"] != false { + t.Fatalf("root lesson must be unlocked") + } + second := lessons[1].(map[string]any) + if second["locked"] != true { + t.Fatalf("prereq lesson must start locked") + } +} + +func TestPrerequisiteLessonRejectsDirectAccess(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Direct", "direct@test.dev") + + for _, endpoint := range []struct { + method string + path string + body map[string]any + }{ + {method: "GET", path: "/api/lessons/2"}, + {method: "GET", path: "/api/lessons/2/resume"}, + {method: "POST", path: "/api/lessons/2/heartbeat", body: map[string]any{"position": 1, "delta": 1, "seq": 1}}, + {method: "POST", path: "/api/lessons/2/complete"}, + {method: "GET", path: "/api/lessons/2/quiz"}, + {method: "POST", path: "/api/lessons/2/quiz/submit", body: map[string]any{"answers": []int{0}}}, + {method: "GET", path: "/api/lessons/2/quiz/result"}, + } { + code, _ := e.do(t, endpoint.method, endpoint.path, token, endpoint.body) + if code != http.StatusForbidden { + t.Fatalf("%s %s: expected 403, got %d", endpoint.method, endpoint.path, code) + } + } +} + +func TestHeartbeatReportsUnlockTransition(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Heartbeat", "heartbeat@test.dev") + + for i := 1; i <= 5; i++ { + rewindWatchSession(t, e, token, 1) + code, body := e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{ + "position": float64(i * 7), + "delta": 7, + "seq": i, + }) + if code != http.StatusOK || body["quizUnlocked"] != false || body["quizUnlockedNow"] != false { + t.Fatalf("heartbeat %d should remain locked: %d %v", i, code, body) + } + } + + rewindWatchSession(t, e, token, 1) + code, body := e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{ + "position": 42, "delta": 4, "seq": 6, + }) + if code != http.StatusOK || body["quizUnlocked"] != true || body["quizUnlockedNow"] != true { + t.Fatalf("unlocking heartbeat should report transition: %d %v", code, body) + } + + rewindWatchSession(t, e, token, 1) + code, body = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{ + "position": 43, "delta": 1, "seq": 7, + }) + if code != http.StatusOK || body["quizUnlocked"] != true || body["quizUnlockedNow"] != false { + t.Fatalf("subsequent heartbeat should report persistent unlock only: %d %v", code, body) + } +} + +func TestQuizRequiresWatch(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Cheat", "cheat@test.dev") + code, _ := e.do(t, "GET", "/api/lessons/1/quiz", token, nil) + if code != http.StatusForbidden { + t.Fatalf("quiz must be locked before watch, got %d", code) + } +} + +func TestWatchUnlocksQuizAndPass(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Win", "win@test.dev") + + watchToUnlock(t, e, token, 1) + + code, body := e.do(t, "GET", "/api/lessons/1/quiz", token, nil) + if code != http.StatusOK { + t.Fatalf("quiz not unlocked after watch: %d", code) + } + questions := body["questions"].([]any) + if len(questions) != 3 { + t.Fatalf("expected 3 questions") + } + // answer keys must never leak to clients + for _, q := range questions { + qm := q.(map[string]any) + if _, ok := qm["answerIndex"]; ok { + t.Fatalf("answer key leaked to client!") + } + } + + // graded server-side; correct answers for seeded lesson 1 are [1,1,2] + code, result := e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}}) + if code != http.StatusOK { + t.Fatalf("submit: %d %v", code, result) + } + if result["passed"] != true { + t.Fatalf("expected pass, got %v", result) + } + if result["xpEarned"].(float64) <= 0 { + t.Fatalf("expected xp") + } + + // lessons 2 and 3 must now be unlocked + _, body = e.do(t, "GET", "/api/tree", token, nil) + chapters := body["chapters"].([]any) + found := 0 + for _, ch := range chapters { + for _, l := range ch.(map[string]any)["lessons"].([]any) { + lm := l.(map[string]any) + switch lm["id"].(float64) { + case 2, 3: + if lm["locked"] != false { + t.Fatalf("lesson %v should be unlocked after passing prereq", lm["id"]) + } + found++ + } + } + } + if found != 2 { + t.Fatalf("expected 2 unlocked lessons, got %d", found) + } +} + +func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Struggle", "struggle@test.dev") + + watchToUnlock(t, e, token, 1) + + // all-wrong answers burn 3 hearts -> account locked + code, result := e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}}) + if code != http.StatusOK { + t.Fatalf("submit: %d", code) + } + if result["heartsLeft"].(float64) != 0 { + t.Fatalf("expected 0 hearts") + } + if result["locked"] != true { + t.Fatalf("expected locked") + } + + // locked users can no longer send heartbeats (423) + code, _ = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{"position": 45, "delta": 1, "seq": 9}) + if code != http.StatusLocked { + t.Fatalf("expected 423 while locked, got %d", code) + } + + // locked users may request staff review (does not unlock); only staff unlocks + code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "لطفاً بررسی کنید"}) + if code != http.StatusCreated && code != http.StatusOK { + t.Fatalf("unlock request: %d %v", code, body) + } + code, _ = e.do(t, "POST", "/api/me/request-reset", token, nil) + if code != http.StatusForbidden { + t.Fatalf("self reset should be forbidden, got %d", code) + } + + // mentor unlocks (account CRUD is admin-only; unlock remains staff) + admin := login(t, e, bootstrapAdminEmail) + code, _ = e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d", code) + } + mentor := login(t, e, "mentor@test.dev") + + code, _ = e.do(t, "GET", "/api/admin/users?q=struggle", mentor, nil) + if code != http.StatusForbidden { + t.Fatalf("mentor listing users should be 403, got %d", code) + } + code, body = e.do(t, "GET", "/api/admin/learning/students?q=struggle", mentor, nil) + if code != http.StatusOK { + t.Fatalf("mentor learning students: %d %v", code, body) + } + + code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/unlock", mentor, nil) + if code != http.StatusOK { + t.Fatalf("unlock: %d", code) + } + + code, body = e.do(t, "GET", "/api/auth/me", token, nil) + u := body["user"].(map[string]any) + if code != http.StatusOK || u["isLocked"] != false { + t.Fatalf("user should be unlocked: %v", u["isLocked"]) + } + if u["hearts"].(float64) != 3 { + t.Fatalf("hearts should be restored to 3, got %v", u["hearts"]) + } +} + +func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Overflow", "overflow@test.dev") + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "GET", "/api/admin/users?q=overflow", admin, nil) + if code != http.StatusOK { + t.Fatalf("admin users: %d", code) + } + uid := int64(body["items"].([]any)[0].(map[string]any)["id"].(float64)) + + code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 4}) + if code != http.StatusOK { + t.Fatalf("update user: %d", code) + } + user := body["user"].(map[string]any) + if user["hearts"].(float64) != 3 || user["isLocked"] != true { + t.Fatalf("overflow must cap hearts and lock account: %v", user) + } + + code, body = e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + user = body["user"].(map[string]any) + if user["hearts"].(float64) != 3 || user["isLocked"] != true { + t.Fatalf("persisted overflow guard failed: %v", user) + } +} + +func TestCannotChangeOwnRoleOrLockSelf(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "GET", "/api/auth/me", admin, nil) + if code != http.StatusOK { + t.Fatalf("me: %d", code) + } + adminID := int64(body["user"].(map[string]any)["id"].(float64)) + + code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(adminID), admin, map[string]any{"role": "student"}) + if code != http.StatusBadRequest { + t.Fatalf("self role change via update should be 400, got %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/users/"+itoa(adminID)+"/role", admin, map[string]any{"role": "mentor"}) + if code != http.StatusBadRequest { + t.Fatalf("self role change via set-role should be 400, got %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/users/"+itoa(adminID)+"/lock", admin, nil) + if code != http.StatusBadRequest { + t.Fatalf("self lock should be 400, got %d %v", code, body) + } + + code, body = e.do(t, "GET", "/api/auth/me", admin, nil) + if code != http.StatusOK { + t.Fatalf("me after: %d", code) + } + if body["user"].(map[string]any)["role"] != "admin" { + t.Fatalf("admin role must remain admin, got %v", body["user"]) + } +} + +func TestEventsRsvpICSAndChat(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Social", "social@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + + code, body = e.do(t, "GET", "/api/events", token, nil) + if code != http.StatusOK { + t.Fatalf("events: %d", code) + } + events := body["events"].([]any) + if len(events) != 3 { + t.Fatalf("expected 3 seeded events, got %d", len(events)) + } + evID := int64(events[0].(map[string]any)["id"].(float64)) + + code, _ = e.do(t, "POST", "/api/events/"+itoa(evID)+"/rsvp", token, nil) + if code != http.StatusOK { + t.Fatalf("rsvp: %d", code) + } + + // authenticated ICS download + req, _ := http.NewRequest("GET", e.server.URL+"/api/events/"+itoa(evID)+"/ics", nil) + req.Header.Set("Authorization", "Bearer "+token) + iresp, _ := http.DefaultClient.Do(req) + if iresp.StatusCode != http.StatusOK { + t.Fatalf("ics: %d", iresp.StatusCode) + } + + // chat with mentor (id 2) + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", token, map[string]any{"body": "hello mentor"}) + if code != http.StatusCreated { + t.Fatalf("chat: %d %v", code, body) + } + code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages", token, nil) + if code != http.StatusOK { + t.Fatalf("messages: %d", code) + } + msgs := body["messages"].([]any) + if len(msgs) != 1 || msgs[0].(map[string]any)["body"] != "hello mentor" { + t.Fatalf("chat roundtrip failed: %v", msgs) + } +} + +func TestChatRecipientPermissions(t *testing.T) { + e := setup(t) + studentToken, studentID := register(t, e, "One", "one@test.dev") + _, otherStudentID := register(t, e, "Two", "two@test.dev") + adminToken := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", adminToken, map[string]any{ + "name": "Mentor Maya", "email": "mentor@test.dev", "username": "mentor", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + + for _, target := range []int64{studentID, otherStudentID} { + code, _ = e.do(t, "POST", "/api/chats/"+itoa(target)+"/messages", studentToken, map[string]any{"body": "not allowed"}) + if code != http.StatusForbidden { + t.Fatalf("student→student must be forbidden, got %d", code) + } + } + code, _ = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", studentToken, map[string]any{"body": "ok"}) + if code != http.StatusCreated { + t.Fatalf("student→mentor should work: %d", code) + } +} + +func TestChatAroundWindow(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Around", "around@test.dev") + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "Mentor Around", "email": "mentor-around@test.dev", "username": "mentoraround", "password": "password123", "role": "mentor", + "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", + }) + if code != http.StatusCreated { + t.Fatalf("create mentor: %d %v", code, body) + } + mentorID := int64(body["user"].(map[string]any)["id"].(float64)) + + var mid int64 + for i := 0; i < 5; i++ { + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", token, map[string]any{ + "body": "msg-" + itoa(int64(i)), + }) + if code != http.StatusCreated { + t.Fatalf("send %d: %d %v", i, code, body) + } + id := int64(body["message"].(map[string]any)["id"].(float64)) + if i == 2 { + mid = id + } + } + if mid == 0 { + t.Fatal("missing midpoint id") + } + + code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages?around="+itoa(mid), token, nil) + if code != http.StatusOK { + t.Fatalf("around: %d %v", code, body) + } + msgs := body["messages"].([]any) + found := false + for _, raw := range msgs { + if int64(raw.(map[string]any)["id"].(float64)) == mid { + found = true + break + } + } + if !found { + t.Fatalf("around window missing target %d: %v", mid, msgs) + } + if int64(body["around"].(float64)) != mid { + t.Fatalf("around echo mismatch: %v", body["around"]) + } + + code, body = e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages/"+itoa(mid)+"/pin", token, nil) + if code != http.StatusOK { + t.Fatalf("pin: %d %v", code, body) + } + code, body = e.do(t, "GET", "/api/chats/"+itoa(mentorID)+"/messages", token, nil) + if code != http.StatusOK { + t.Fatalf("list: %d", code) + } + pinned := body["pinned"].([]any) + if len(pinned) != 1 { + t.Fatalf("expected 1 pin, got %d", len(pinned)) + } +} + +func TestUnlockRequestQueuesStaffReview(t *testing.T) { + e := setup(t) + token, studentID := register(t, e, "LockedKid", "lockedkid@test.dev") + admin := login(t, e, bootstrapAdminEmail) + + code, _ := e.do(t, "POST", "/api/admin/users/"+itoa(studentID)+"/lock", admin, nil) + if code != http.StatusOK { + t.Fatalf("admin lock: %d", code) + } + + code, body := e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "کمک لازم دارم"}) + if code != http.StatusCreated { + t.Fatalf("unlock request: %d %v", code, body) + } + // second request within window should be idempotent OK + code, body = e.do(t, "POST", "/api/me/request-unlock", token, map[string]any{"note": "دوباره"}) + if code != http.StatusOK { + t.Fatalf("repeat unlock request: %d %v", code, body) + } + + code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(studentID)+"/unlock", admin, nil) + if code != http.StatusOK { + t.Fatalf("admin unlock: %d", code) + } + code, body = e.do(t, "GET", "/api/auth/me", token, nil) + if code != http.StatusOK || body["user"].(map[string]any)["isLocked"] != false { + t.Fatalf("should be unlocked: %d %v", code, body) + } +} + +func TestNotificationsFanOutAndPrefs(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Notified", "notified@test.dev") + + // prefs exist & mutable + code, body := e.do(t, "GET", "/api/notifications/preferences", token, nil) + if code != http.StatusOK { + t.Fatalf("prefs: %d", code) + } + code, _ = e.do(t, "PUT", "/api/notifications/preferences", token, map[string]any{ + "progress": false, "gamification": true, "mentor": true, "event": false, + }) + if code != http.StatusOK { + t.Fatalf("put prefs: %d", code) + } + + // produce notifications (gamification on, progress off) + watchToUnlock(t, e, token, 1) + e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}}) + + code, body = e.do(t, "GET", "/api/notifications", token, nil) + if code != http.StatusOK { + t.Fatalf("notifs: %d", code) + } + unread := int(body["unread"].(float64)) + if unread == 0 { + t.Fatal("expected notifications to be created") + } + found := false + for _, n := range body["notifications"].([]any) { + if n.(map[string]any)["type"] == "quiz_passed" { + found = true + } + } + if !found { + t.Fatal("expected gamification quiz_passed notification") + } + + code, body = e.do(t, "GET", "/api/notifications/unread-count", token, nil) + if code != http.StatusOK || int(body["unread"].(float64)) != unread { + t.Fatalf("unread count mismatch") + } + + code, _ = e.do(t, "POST", "/api/notifications/read-all", token, nil) + if code != http.StatusOK { + t.Fatalf("read-all: %d", code) + } + code, body = e.do(t, "GET", "/api/notifications/unread-count", token, nil) + if code != http.StatusOK || int(body["unread"].(float64)) != 0 { + t.Fatalf("expected 0 unread after read-all, got %v", body) + } +} + +func TestLeaderboardAfterXP(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Orc", "orc@test.dev") + watchToUnlock(t, e, token, 1) + e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{1, 1, 2}}) + + code, body := e.do(t, "GET", "/api/leaderboard/weekly", token, nil) + if code != http.StatusOK { + t.Fatalf("leaderboard: %d", code) + } + entries := body["entries"].([]any) + if len(entries) == 0 { + t.Fatal("expected leaderboard entries") + } + if entries[0].(map[string]any)["xp"].(float64) <= 0 { + t.Fatalf("expected positive xp") + } + fmt.Println("leaderboard ok") +} + +func TestAdminSettingsAndSponsors(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ + "settings": map[string]any{ + "donation_enabled": "true", + "donation_note": "یادداشت تست", + "sponsors": `[{"name":"حامی یک","url":"https://example.com","blurb":"حمایت"}]`, + }, + }) + if code != http.StatusOK { + t.Fatalf("put settings: %d %v", code, body) + } + settings := body["settings"].(map[string]any) + if settings["donation_note"] != "یادداشت تست" { + t.Fatalf("donation note not saved: %v", settings["donation_note"]) + } + + code, body = e.do(t, "GET", "/api/sponsors", "", nil) + if code != http.StatusOK { + t.Fatalf("sponsors: %d %v", code, body) + } + sponsors := body["sponsors"].([]any) + if len(sponsors) != 1 || sponsors[0].(map[string]any)["name"] != "حامی یک" { + t.Fatalf("unexpected sponsors: %v", sponsors) + } + + code, body = e.do(t, "GET", "/api/community", "", nil) + if code != http.StatusOK { + t.Fatalf("community: %d %v", code, body) + } + if _, ok := body["channel"]; ok { + t.Fatalf("community must not expose telegram channel: %v", body["channel"]) + } + donation := body["donation"].(map[string]any) + if donation["note"] != "یادداشت تست" { + t.Fatalf("community donation note: %v", donation["note"]) + } +} diff --git a/backend/internal/api/http_middleware.go b/backend/internal/api/http_middleware.go new file mode 100644 index 0000000..6d3df55 --- /dev/null +++ b/backend/internal/api/http_middleware.go @@ -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[:]) +} diff --git a/backend/internal/api/identity_test.go b/backend/internal/api/identity_test.go new file mode 100644 index 0000000..87f4052 --- /dev/null +++ b/backend/internal/api/identity_test.go @@ -0,0 +1,223 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestLoginWithUsername(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "ULogin", "email": "ulogin@test.dev", "username": "ulogin", + "password": "password123", "role": "student", + "phone": "09121112233", "securityQuestion": "رنگ مورد علاقه؟", "securityAnswer": "آبی", + }) + if code != http.StatusCreated { + t.Fatalf("create: %d %v", code, body) + } + code, body = e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "ulogin", "password123")) + if code != http.StatusOK { + t.Fatalf("username login: %d %v", code, body) + } +} + +func TestUniquePhone(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + code, _ := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "P1", "email": "p1@test.dev", "username": "p1user", + "password": "password123", "role": "student", + "phone": "09120000001", "securityQuestion": "رنگ؟", "securityAnswer": "آبی", + }) + if code != http.StatusCreated { + t.Fatalf("first: %d", code) + } + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "P2", "email": "p2@test.dev", "username": "p2user", + "password": "password123", "role": "student", + "phone": "09120000001", "securityQuestion": "رنگ؟", "securityAnswer": "آبی", + }) + if code != http.StatusConflict { + t.Fatalf("expected phone conflict, got %d %v", code, body) + } +} + +func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "ResetMe", "email": "resetme@test.dev", "username": "resetme", + "password": "password123", "role": "student", + "securityQuestion": "پایتخت؟", "securityAnswer": "تهران", + }) + if code != http.StatusCreated { + t.Fatalf("create: %d %v", code, body) + } + uid := int64(body["user"].(map[string]any)["id"].(float64)) + + code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{ + "securityAnswer": "اشتباه", "newPassword": "newpass123", + }) + if code != http.StatusForbidden { + t.Fatalf("wrong answer should 403, got %d", code) + } + + code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{ + "securityAnswer": "تهران", "newPassword": "newpass123", + }) + if code != http.StatusOK { + t.Fatalf("reset: %d %v", code, body) + } + + code, _ = e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "resetme", "newpass123")) + if code != http.StatusOK { + t.Fatalf("login with new password failed: %d", code) + } +} + +func TestCertificateIssueAndPublicAccess(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "Cert", "cert@test.dev") + + code, body := e.do(t, "POST", "/api/me/certificate/issue", token, nil) + if code != http.StatusConflict { + t.Fatalf("issue before complete should conflict: %d %v", code, body) + } + + // Mark all active lessons passed directly for speed. + _, err := e.pool.Exec(context.Background(), ` + INSERT INTO lesson_progress (user_id, lesson_id, watched_seconds, watched_pct, last_position, quiz_unlocked, passed_quiz, quiz_completed_at) + SELECT $1, id, duration_seconds, 100, duration_seconds, true, true, now() + FROM lessons WHERE is_active + ON CONFLICT (user_id, lesson_id) DO UPDATE SET passed_quiz=true, quiz_unlocked=true`, uid) + if err != nil { + t.Fatalf("seed progress: %v", err) + } + + code, body = e.do(t, "GET", "/api/me/certificate", token, nil) + if code != http.StatusOK { + t.Fatalf("mine before issue: %d %v", code, body) + } + if body["eligible"] != true { + t.Fatalf("expected eligible=true before issue, got %v", body["eligible"]) + } + if body["certificate"] != nil { + t.Fatalf("expected nil certificate before issue, got %v", body["certificate"]) + } + + code, body = e.do(t, "POST", "/api/me/certificate/issue", token, nil) + if code != http.StatusOK { + t.Fatalf("issue: %d %v", code, body) + } + cert := body["certificate"].(map[string]any) + publicID := cert["publicId"].(string) + if publicID == "" { + t.Fatal("missing publicId") + } + + if len(publicID) != 32 { + t.Fatalf("publicId should be 32-char hex token, got %q (len=%d)", publicID, len(publicID)) + } + for _, r := range publicID { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + t.Fatalf("publicId must be lowercase hex, got %q", publicID) + } + } + + req, err := http.NewRequest("GET", e.server.URL+"/api/certificates/"+publicID, nil) + if err != nil { + t.Fatalf("build public cert req: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("public cert: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("public cert: %d", resp.StatusCode) + } + wantRobots := "noindex, nofollow, noarchive" + if got := resp.Header.Get("X-Robots-Tag"); got != wantRobots { + t.Fatalf("X-Robots-Tag: got %q want %q", got, wantRobots) + } + var pubBody map[string]any + if err := json.NewDecoder(resp.Body).Decode(&pubBody); err != nil { + t.Fatalf("decode public cert: %v", err) + } + if pubBody["certificate"].(map[string]any)["fullName"] != "Cert" { + t.Fatalf("unexpected public payload: %v", pubBody) + } + + code, body = e.do(t, "GET", "/api/me/certificate", token, nil) + if code != http.StatusOK { + t.Fatalf("mine after issue: %d %v", code, body) + } + if body["eligible"] != true { + t.Fatalf("expected eligible after issue, got %v", body["eligible"]) + } + mineCert, _ := body["certificate"].(map[string]any) + if mineCert == nil || mineCert["publicId"] != publicID { + t.Fatalf("mine certificate: %v", body["certificate"]) + } + + // Missing cert still sends noindex so 404s are not indexed. + missReq, _ := http.NewRequest("GET", e.server.URL+"/api/certificates/does-not-exist", nil) + missResp, err := http.DefaultClient.Do(missReq) + if err != nil { + t.Fatalf("missing cert: %v", err) + } + missResp.Body.Close() + if missResp.StatusCode != http.StatusNotFound { + t.Fatalf("missing cert status: %d", missResp.StatusCode) + } + if got := missResp.Header.Get("X-Robots-Tag"); got != wantRobots { + t.Fatalf("missing cert X-Robots-Tag: got %q want %q", got, wantRobots) + } + + code, body = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{ + "recipientName": "Cert", + "phone": "09120000000", + "address": "خیابان نمونه پلاک ۱", + "city": "تهران", + "postalCode": "1234567890", + }) + if code != http.StatusCreated { + t.Fatalf("physical request: %d %v", code, body) + } + + code, _ = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{ + "address": "x", "city": "y", + }) + if code != http.StatusConflict { + t.Fatalf("duplicate physical should conflict: %d", code) + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/physical-orders", admin, nil) + if code != http.StatusOK { + t.Fatalf("list orders: %d", code) + } + orders := body["orders"].([]any) + if len(orders) < 1 { + t.Fatal("expected orders") + } + orderID := int64(orders[0].(map[string]any)["id"].(float64)) + code, body = e.do(t, "PUT", "/api/admin/physical-orders/"+itoa(orderID), admin, map[string]any{ + "status": "paid", "note": "واریز شد", + }) + if code != http.StatusOK { + t.Fatalf("mark paid: %d %v", code, body) + } + code, body = e.do(t, "PUT", "/api/admin/physical-orders/"+itoa(orderID), admin, map[string]any{ + "status": "shipped", "trackingCode": "TRK-99", + }) + if code != http.StatusOK { + t.Fatalf("mark shipped: %d %v", code, body) + } + if body["order"].(map[string]any)["trackingCode"] != "TRK-99" { + t.Fatalf("expected tracking code, got %v", body) + } +} diff --git a/backend/internal/api/invites.go b/backend/internal/api/invites.go new file mode 100644 index 0000000..2e25d3c --- /dev/null +++ b/backend/internal/api/invites.go @@ -0,0 +1,464 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +func invitePublicPath(token string) string { + return "/register/invite/" + token +} + +func (s *Server) inviteURL(token string) string { + base := strings.TrimRight(s.cfg.PublicURL, "/") + if base == "" { + return invitePublicPath(token) + } + return base + invitePublicPath(token) +} + +func (s *Server) decorateInvite(inv *models.RegistrationInvite) { + if inv == nil { + return + } + enc := inv.Token + inv.Token = "" + if enc == "" { + return + } + plain, err := store.DecryptInviteToken(s.cfg.JWTSecret, enc) + if err != nil { + return + } + inv.Token = plain + inv.URL = s.inviteURL(plain) +} + +func inviteErrorStatus(err error) (int, string) { + switch { + case errors.Is(err, store.ErrInviteNotFound), errors.Is(err, store.ErrInviteInvalid): + return http.StatusNotFound, "لینک عضویت نامعتبر است" + case errors.Is(err, store.ErrInvitePaused), + errors.Is(err, store.ErrInviteRevoked), + errors.Is(err, store.ErrInviteExhausted), + errors.Is(err, store.ErrInviteExpired): + // Generic message for public clients — do not reveal capacity or pause state. + return http.StatusGone, "این لینک عضویت قابل استفاده نیست" + default: + return http.StatusInternalServerError, "بررسی لینک عضویت ممکن نشد" + } +} + +func (s *Server) handleGetRegisterInvite(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.PathValue("token")) + if token == "" { + writeErr(w, http.StatusBadRequest, "توکن نامعتبر است") + return + } + inv, err := s.store.GetRegistrationInviteByTokenHash(r.Context(), store.HashInviteToken(token)) + if err != nil { + code, msg := inviteErrorStatus(err) + writeErr(w, code, msg) + return + } + if err := store.EvaluateInviteAvailability(inv, time.Now()); err != nil { + code, msg := inviteErrorStatus(err) + writeErr(w, code, msg) + return + } + out := map[string]any{"valid": true} + if inv.Label != "" { + out["label"] = inv.Label + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleRegisterInvite(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.PathValue("token")) + if token == "" { + writeErr(w, http.StatusBadRequest, "توکن نامعتبر است") + return + } + + inv, err := s.store.GetRegistrationInviteByTokenHash(r.Context(), store.HashInviteToken(token)) + if err != nil { + code, msg := inviteErrorStatus(err) + writeErr(w, code, msg) + return + } + if err := store.EvaluateInviteAvailability(inv, time.Now()); err != nil { + code, msg := inviteErrorStatus(err) + writeErr(w, code, msg) + return + } + + var req registerRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + + name := strings.TrimSpace(req.Name) + email := strings.ToLower(strings.TrimSpace(req.Email)) + username := strings.ToLower(strings.TrimSpace(req.Username)) + phone := normalizePhone(req.Phone) + secQ := strings.TrimSpace(req.SecurityQuestion) + secA := strings.TrimSpace(req.SecurityAnswer) + + if msg := validateStudentRegistrationFields( + name, email, username, phone, req.Password, req.PasswordConfirm, secQ, secA, + ); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + if err := s.consumeCaptcha(r.Context(), req.ChallengeID, req.CaptchaAnswer); err != nil { + s.recordAuthFailure(r.Context(), "register-invite", clientAddress(r)) + code, msg := captchaErrorMessage(err) + writeErr(w, code, msg) + return + } + inviteID := inv.ID + if s.rejectIfBlacklisted(w, r, phone, "invite", &inviteID, username, email) { + s.recordAuthFailure(r.Context(), "register-invite", clientAddress(r)) + return + } + + ip := clientAddress(r) + if ip == "" || ip == "unknown" { + writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد") + return + } + if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil { + writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبت‌نام ممکن نشد") + return + } else if taken { + writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است") + return + } + + if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil { + writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی ایمیل ممکن نشد") + return + } + if _, err := s.store.GetUserByUsername(r.Context(), username); err == nil { + writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی شناسه کاربری ممکن نشد") + return + } + if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil { + writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی شماره تلفن ممکن نشد") + return + } + + hash, err := s.auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد") + return + } + answerHash, err := s.auth.HashPassword(strings.ToLower(secA)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد") + return + } + + user, err := s.store.RegisterInviteStudent( + r.Context(), + inv.ID, + name, email, username, hash, phone, secQ, answerHash, ip, + ) + if err != nil { + if errors.Is(err, store.ErrPhoneBlacklisted) { + s.recordAuthFailure(r.Context(), "register-invite", ip) + writeErr(w, http.StatusForbidden, "این شماره مجاز به ثبت‌نام نیست") + return + } + if errors.Is(err, store.ErrRegistrationIPTaken) { + writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است") + return + } + if errors.Is(err, store.ErrInviteRevoked) || + errors.Is(err, store.ErrInvitePaused) || + errors.Is(err, store.ErrInviteExhausted) || + errors.Is(err, store.ErrInviteExpired) || + errors.Is(err, store.ErrInviteInvalid) || + errors.Is(err, store.ErrInviteNotFound) { + code, msg := inviteErrorStatus(err) + writeErr(w, code, msg) + return + } + if store.IsUniqueViolation(err) { + writeErr(w, http.StatusConflict, "این اطلاعات قبلاً ثبت شده است") + return + } + writeErr(w, http.StatusInternalServerError, "ثبت‌نام ممکن نشد") + return + } + + access, refresh, err := s.auth.IssueForUser(r.Context(), user) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد") + return + } + if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil { + meta.UserID = user.ID + } + s.clearAuthFailures(r.Context(), "register-invite", ip) + observability.Logins.WithLabelValues("ok").Inc() + observability.Activity("auth.register_invite", "user_id", user.ID, "invite_id", inv.ID, "remote", ip) + s.signUserMedia(user) + s.setSessionCookies(w, access, refresh, s.cfg.RefreshTokenTTL) + writeJSON(w, http.StatusCreated, map[string]any{ + "accessToken": access, "refreshToken": refresh, "user": user, + }) +} + +type createInviteRequest struct { + MaxUses int `json:"maxUses"` + Label string `json:"label"` + ExpiresInDays *int `json:"expiresInDays"` +} + +func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) { + var req createInviteRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if req.MaxUses < 1 || req.MaxUses > 10_000 { + writeErr(w, http.StatusBadRequest, "ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد") + return + } + label := strings.TrimSpace(req.Label) + if msg := validateInviteLabel(label); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + var expiresAt *time.Time + if req.ExpiresInDays != nil { + days := *req.ExpiresInDays + if days < 1 || days > 3650 { + writeErr(w, http.StatusBadRequest, "مهلت باید بین ۱ تا ۳۶۵۰ روز باشد") + return + } + t := time.Now().Add(time.Duration(days) * 24 * time.Hour) + expiresAt = &t + } + + raw, err := store.NewInviteToken() + if err != nil { + writeErr(w, http.StatusInternalServerError, "ساخت توکن ممکن نشد") + return + } + enc, err := store.EncryptInviteToken(s.cfg.JWTSecret, raw) + if err != nil { + writeErr(w, http.StatusInternalServerError, "رمزنگاری توکن ممکن نشد") + return + } + + admin := currentUser(r) + inv, err := s.store.CreateRegistrationInvite( + r.Context(), + store.HashInviteToken(raw), + enc, + label, + req.MaxUses, + expiresAt, + admin.ID, + ) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره لینک عضویت ممکن نشد") + return + } + inv.Token = raw + inv.URL = s.inviteURL(raw) + observability.Activity("admin.invite_create", "invite_id", inv.ID, "max_uses", inv.MaxUses, "admin_id", admin.ID) + writeJSON(w, http.StatusCreated, map[string]any{"invite": inv}) +} + +func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + p := parsePageParams(r) + invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری لینک‌های عضویت ممکن نشد") + return + } + for i := range invites { + s.decorateInvite(&invites[i]) + } + writePage(w, invites, total, p) +} + +func (s *Server) handleAdminRevokeInvite(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + inv, err := s.store.RevokeRegistrationInvite(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrInviteNotFound) { + writeErr(w, http.StatusNotFound, "لینک عضویت یافت نشد") + return + } + if errors.Is(err, store.ErrInviteInvalid) { + s.decorateInvite(inv) + writeJSON(w, http.StatusOK, map[string]any{"invite": inv, "alreadyInactive": true}) + return + } + writeErr(w, http.StatusInternalServerError, "لغو لینک ممکن نشد") + return + } + s.decorateInvite(inv) + observability.Activity("admin.invite_revoke", "invite_id", inv.ID, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"invite": inv}) +} + +func (s *Server) handleAdminPauseInvite(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + inv, err := s.store.PauseRegistrationInvite(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrInviteNotFound) { + writeErr(w, http.StatusNotFound, "لینک عضویت یافت نشد") + return + } + if errors.Is(err, store.ErrInviteInvalid) { + writeErr(w, http.StatusConflict, "فقط لینک فعال قابل توقف موقت است") + return + } + writeErr(w, http.StatusInternalServerError, "توقف موقت لینک ممکن نشد") + return + } + s.decorateInvite(inv) + observability.Activity("admin.invite_pause", "invite_id", inv.ID, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"invite": inv}) +} + +func (s *Server) handleAdminResumeInvite(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + inv, err := s.store.ResumeRegistrationInvite(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrInviteNotFound) { + writeErr(w, http.StatusNotFound, "لینک عضویت یافت نشد") + return + } + if errors.Is(err, store.ErrInviteExpired) { + writeErr(w, http.StatusConflict, "مهلت این لینک به پایان رسیده است") + return + } + if errors.Is(err, store.ErrInviteExhausted) { + writeErr(w, http.StatusConflict, "ظرفیت این لینک تکمیل شده است") + return + } + if errors.Is(err, store.ErrInviteInvalid) { + writeErr(w, http.StatusConflict, "فقط لینک متوقف‌شده قابل ازسرگیری است") + return + } + writeErr(w, http.StatusInternalServerError, "ازسرگیری لینک ممکن نشد") + return + } + s.decorateInvite(inv) + observability.Activity("admin.invite_resume", "invite_id", inv.ID, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"invite": inv}) +} + +type updateInviteRequest struct { + Label *string `json:"label"` + MaxUses *int `json:"maxUses"` + ExpiresInDays *int `json:"expiresInDays"` + ClearExpires *bool `json:"clearExpires"` +} + +func (s *Server) handleAdminUpdateInvite(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + var req updateInviteRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if req.Label == nil && req.MaxUses == nil && req.ExpiresInDays == nil && req.ClearExpires == nil { + writeErr(w, http.StatusBadRequest, "هیچ فیلدی برای ویرایش ارسال نشده است") + return + } + if req.MaxUses != nil && (*req.MaxUses < 1 || *req.MaxUses > 10_000) { + writeErr(w, http.StatusBadRequest, "ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد") + return + } + var labelPtr *string + if req.Label != nil { + trimmed := strings.TrimSpace(*req.Label) + if msg := validateInviteLabel(trimmed); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + labelPtr = &trimmed + } + + setExpires := false + var expiresAt *time.Time + if req.ClearExpires != nil && *req.ClearExpires { + setExpires = true + expiresAt = nil + } else if req.ExpiresInDays != nil { + days := *req.ExpiresInDays + if days < 1 || days > 3650 { + writeErr(w, http.StatusBadRequest, "مهلت باید بین ۱ تا ۳۶۵۰ روز باشد") + return + } + t := time.Now().Add(time.Duration(days) * 24 * time.Hour) + setExpires = true + expiresAt = &t + } + + inv, err := s.store.UpdateRegistrationInvite(r.Context(), id, labelPtr, req.MaxUses, setExpires, expiresAt) + if err != nil { + if errors.Is(err, store.ErrInviteNotFound) { + writeErr(w, http.StatusNotFound, "لینک عضویت یافت نشد") + return + } + if errors.Is(err, store.ErrInviteInvalid) { + writeErr(w, http.StatusConflict, "لینک لغو یا تکمیل‌شده قابل ویرایش نیست") + return + } + if errors.Is(err, store.ErrInviteMaxUsesTooLow) { + writeErr(w, http.StatusBadRequest, "ظرفیت نمی‌تواند کمتر از تعداد استفاده‌شده باشد") + return + } + if errors.Is(err, store.ErrInviteMaxUsesRange) { + writeErr(w, http.StatusBadRequest, "ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد") + return + } + writeErr(w, http.StatusInternalServerError, "ویرایش لینک ممکن نشد") + return + } + s.decorateInvite(inv) + observability.Activity("admin.invite_update", "invite_id", inv.ID, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"invite": inv}) +} diff --git a/backend/internal/api/invites_test.go b/backend/internal/api/invites_test.go new file mode 100644 index 0000000..7cd2f26 --- /dev/null +++ b/backend/internal/api/invites_test.go @@ -0,0 +1,390 @@ +package api_test + +import ( + "net/http" + "testing" +) + +func createInvite(t *testing.T, e *testEnv, maxUses int, label string, expiresInDays *int) map[string]any { + t.Helper() + admin := login(t, e, bootstrapAdminEmail) + body := map[string]any{"maxUses": maxUses, "label": label} + if expiresInDays != nil { + body["expiresInDays"] = *expiresInDays + } + code, resp := e.do(t, "POST", "/api/admin/invites", admin, body) + if code != http.StatusCreated { + t.Fatalf("create invite: %d %v", code, resp) + } + inv, ok := resp["invite"].(map[string]any) + if !ok || inv["token"] == nil || inv["url"] == nil { + t.Fatalf("missing invite token/url: %v", resp) + } + return inv +} + +func TestInviteRegisterSuccessWhenPublicDisabled(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 3, "دوره بهار", nil) + token := inv["token"].(string) + + code, body := e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusOK { + t.Fatalf("invite status: %d %v", code, body) + } + if body["valid"] != true { + t.Fatalf("expected valid, got %v", body) + } + if _, ok := body["remaining"]; ok { + t.Fatalf("public status must not expose remaining: %v", body) + } + if _, ok := body["maxUses"]; ok { + t.Fatalf("public status must not expose maxUses: %v", body) + } + if _, ok := body["usedCount"]; ok { + t.Fatalf("public status must not expose usedCount: %v", body) + } + if body["label"] != "دوره بهار" { + t.Fatalf("label: %v", body["label"]) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.101", + }, registerBody(t, e, "09122220001", "inv-ok@test.dev", "invok")) + if code != http.StatusCreated { + t.Fatalf("invite register: %d %v", code, body) + } + if body["accessToken"] == nil || body["user"] == nil { + t.Fatalf("missing session: %v", body) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusOK { + t.Fatalf("invite status after use: %d %v", code, body) + } + if body["valid"] != true { + t.Fatalf("expected still valid, got %v", body) + } + if _, ok := body["remaining"]; ok { + t.Fatalf("public status must not expose remaining after use: %v", body) + } +} + +func TestInviteCapacityExhaustion(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 1, "", nil) + token := inv["token"].(string) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.110", + }, registerBody(t, e, "09122220010", "inv-full1@test.dev", "invfull1")) + if code != http.StatusCreated { + t.Fatalf("first seat: %d %v", code, body) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusGone { + t.Fatalf("expected gone when full, got %d %v", code, body) + } + if body["error"] != "این لینک عضویت قابل استفاده نیست" { + t.Fatalf("expected generic message, got %v", body["error"]) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.111", + }, registerBody(t, e, "09122220011", "inv-full2@test.dev", "invfull2")) + if code != http.StatusGone { + t.Fatalf("expected capacity gone, got %d %v", code, body) + } +} + +func TestInviteRevoke(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 5, "لغو", nil) + token := inv["token"].(string) + id := int64(inv["id"].(float64)) + + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/revoke", admin, nil) + if code != http.StatusOK { + t.Fatalf("revoke: %d %v", code, body) + } + revoked := body["invite"].(map[string]any) + if revoked["status"] != "revoked" { + t.Fatalf("expected revoked, got %v", revoked["status"]) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusGone { + t.Fatalf("expected gone after revoke, got %d %v", code, body) + } + if body["error"] != "این لینک عضویت قابل استفاده نیست" { + t.Fatalf("expected generic message, got %v", body["error"]) + } + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.120", + }, registerBody(t, e, "09122220020", "inv-rev@test.dev", "invrev")) + if code != http.StatusGone { + t.Fatalf("expected register gone after revoke, got %d %v", code, body) + } +} + +func TestInviteDuplicatePhoneAndIP(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 5, "", nil) + token := inv["token"].(string) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.130", + }, registerBody(t, e, "09122220030", "inv-dup1@test.dev", "invdup1")) + if code != http.StatusCreated { + t.Fatalf("first: %d %v", code, body) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.131", + }, registerBody(t, e, "09122220030", "inv-dup2@test.dev", "invdup2")) + if code != http.StatusConflict { + t.Fatalf("expected phone conflict, got %d %v", code, body) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.130", + }, registerBody(t, e, "09122220031", "inv-dup3@test.dev", "invdup3")) + if code != http.StatusConflict { + t.Fatalf("expected IP conflict, got %d %v", code, body) + } +} + +func TestInviteAdminListAndPublicStillWorks(t *testing.T) { + e := setup(t) + _ = createInvite(t, e, 2, "لیست", nil) + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "GET", "/api/admin/invites", admin, nil) + if code != http.StatusOK { + t.Fatalf("list: %d %v", code, body) + } + list, ok := body["items"].([]any) + if !ok || len(list) < 1 { + t.Fatalf("expected invites list, got %v", body) + } + first := list[0].(map[string]any) + if first["token"] == nil || first["url"] == nil { + t.Fatalf("list should include token/url for copy: %v", first) + } + if first["remaining"] != float64(2) { + t.Fatalf("remaining: %v", first["remaining"]) + } + + enableRegistration(t, e) + whitelistPhones(t, e, "09122220040") + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.140", + }, registerBody(t, e, "09122220040", "pub-still@test.dev", "pubstill")) + if code != http.StatusCreated { + t.Fatalf("public register should still work: %d %v", code, body) + } +} + +func TestInviteBadToken(t *testing.T) { + e := setup(t) + code, body := e.do(t, "GET", "/api/auth/register-invite/not-a-real-token", "", nil) + if code != http.StatusNotFound { + t.Fatalf("expected not found, got %d %v", code, body) + } +} + +func TestInvitePauseResume(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 5, "توقف", nil) + token := inv["token"].(string) + id := int64(inv["id"].(float64)) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/pause", admin, nil) + if code != http.StatusOK { + t.Fatalf("pause: %d %v", code, body) + } + paused := body["invite"].(map[string]any) + if paused["status"] != "paused" { + t.Fatalf("expected paused, got %v", paused["status"]) + } + if paused["remaining"] != float64(5) { + t.Fatalf("admin should still see remaining while paused: %v", paused["remaining"]) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusGone { + t.Fatalf("expected gone while paused, got %d %v", code, body) + } + if body["error"] != "این لینک عضویت قابل استفاده نیست" { + t.Fatalf("expected generic message, got %v", body["error"]) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.150", + }, registerBody(t, e, "09122220050", "inv-pause@test.dev", "invpause")) + if code != http.StatusGone { + t.Fatalf("register while paused should fail: %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/invites/"+itoa(id)+"/resume", admin, nil) + if code != http.StatusOK { + t.Fatalf("resume: %d %v", code, body) + } + resumed := body["invite"].(map[string]any) + if resumed["status"] != "active" { + t.Fatalf("expected active after resume, got %v", resumed["status"]) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusOK || body["valid"] != true { + t.Fatalf("expected valid after resume: %d %v", code, body) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.151", + }, registerBody(t, e, "09122220051", "inv-resume@test.dev", "invresume")) + if code != http.StatusCreated { + t.Fatalf("register after resume: %d %v", code, body) + } +} + +func TestInviteEditCapacityAndLabel(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 3, "قدیمی", nil) + token := inv["token"].(string) + id := int64(inv["id"].(float64)) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.160", + }, registerBody(t, e, "09122220060", "inv-edit1@test.dev", "invedit1")) + if code != http.StatusCreated { + t.Fatalf("consume one seat: %d %v", code, body) + } + code, body = e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.161", + }, registerBody(t, e, "09122220061", "inv-edit2@test.dev", "invedit2")) + if code != http.StatusCreated { + t.Fatalf("consume second seat: %d %v", code, body) + } + + code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{ + "label": "جدید", + "maxUses": 1, + }) + if code != http.StatusBadRequest { + t.Fatalf("maxUses < usedCount should fail: %d %v", code, body) + } + + code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{ + "label": "جدید", + "maxUses": 4, + "expiresInDays": 7, + }) + if code != http.StatusOK { + t.Fatalf("update invite: %d %v", code, body) + } + updated := body["invite"].(map[string]any) + if updated["label"] != "جدید" { + t.Fatalf("label: %v", updated["label"]) + } + if updated["maxUses"] != float64(4) { + t.Fatalf("maxUses: %v", updated["maxUses"]) + } + if updated["usedCount"] != float64(2) { + t.Fatalf("usedCount: %v", updated["usedCount"]) + } + if updated["remaining"] != float64(2) { + t.Fatalf("remaining: %v", updated["remaining"]) + } + if updated["expiresAt"] == nil { + t.Fatalf("expected expiresAt after update: %v", updated) + } + + code, body = e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusOK || body["valid"] != true { + t.Fatalf("public still valid: %d %v", code, body) + } + if body["label"] != "جدید" { + t.Fatalf("public label: %v", body["label"]) + } + if _, ok := body["maxUses"]; ok { + t.Fatalf("public must not expose capacity: %v", body) + } + + code, body = e.do(t, "PUT", "/api/admin/invites/"+itoa(id), admin, map[string]any{ + "clearExpires": true, + }) + if code != http.StatusOK { + t.Fatalf("clear expires: %d %v", code, body) + } + cleared := body["invite"].(map[string]any) + if cleared["expiresAt"] != nil { + t.Fatalf("expected nil expiresAt, got %v", cleared["expiresAt"]) + } +} + +func TestInvitePublicHidesCapacity(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 10, "محرمانه", nil) + token := inv["token"].(string) + + code, body := e.do(t, "GET", "/api/auth/register-invite/"+token, "", nil) + if code != http.StatusOK { + t.Fatalf("status: %d %v", code, body) + } + for _, key := range []string{"remaining", "maxUses", "usedCount", "expiresAt"} { + if _, ok := body[key]; ok { + t.Fatalf("public payload must not include %s: %v", key, body) + } + } + if body["valid"] != true || body["label"] != "محرمانه" { + t.Fatalf("expected valid+label only essentials: %v", body) + } +} + +func TestInviteListPaginationAndSearch(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + for i := 1; i <= 12; i++ { + label := "عمومی" + if i == 3 { + label = "دوره پاییز ویژه" + } + _ = createInvite(t, e, 1, label, nil) + } + + code, body := e.do(t, "GET", "/api/admin/invites?page=1&pageSize=5", admin, nil) + if code != http.StatusOK { + t.Fatalf("page1: %d %v", code, body) + } + if len(body["items"].([]any)) != 5 { + t.Fatalf("page1 len: %d", len(body["items"].([]any))) + } + if body["total"].(float64) < 12 || body["page"] != float64(1) || body["pageSize"] != float64(5) { + t.Fatalf("page1 meta: %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/invites?page=1", admin, nil) + if code != http.StatusOK { + t.Fatalf("default pageSize: %d %v", code, body) + } + if body["pageSize"] != float64(10) { + t.Fatalf("expected default pageSize 10, got %v", body["pageSize"]) + } + + code, body = e.do(t, "GET", "/api/admin/invites?page=1&pageSize=10&q=پاییز", admin, nil) + if code != http.StatusOK { + t.Fatalf("search: %d %v", code, body) + } + if body["total"] != float64(1) { + t.Fatalf("search total: %v", body["total"]) + } + items := body["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["label"] != "دوره پاییز ویژه" { + t.Fatalf("search items: %v", items) + } +} diff --git a/backend/internal/api/leaderboard.go b/backend/internal/api/leaderboard.go new file mode 100644 index 0000000..971e726 --- /dev/null +++ b/backend/internal/api/leaderboard.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "net/http" + "strconv" + "time" + + "pargar/backend/internal/service" +) + +type lbEntry struct { + UserID int64 `json:"userId"` + Name string `json:"name"` + Email string `json:"email"` + XP float64 `json:"xp"` + Rank int `json:"rank"` + AvatarVariant string `json:"avatarVariant"` + AvatarPalette string `json:"avatarPalette"` + AvatarPhoto string `json:"avatarPhoto,omitempty"` +} + +func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + key := "lb:" + service.ISOWeek(time.Now()) + + entries := make([]lbEntry, 0, 20) + type scored struct { + userID int64 + xp float64 + } + var scoredList []scored + + if s.redis != nil { + z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result() + if err == nil { + for _, member := range z { + id, _ := strconv.ParseInt(member.Member.(string), 10, 64) + scoredList = append(scoredList, scored{userID: id, xp: member.Score}) + } + } + } + + // fallback to DB if redis empty (e.g. dev bootstrap) + if len(scoredList) == 0 { + rows, err := s.store.Pool().Query(context.Background(), ` + SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`, + service.ISOWeek(time.Now())) + if err == nil { + for rows.Next() { + var id int64 + var xp int + if rows.Scan(&id, &xp) == nil { + scoredList = append(scoredList, scored{userID: id, xp: float64(xp)}) + } + } + rows.Close() + } + } + + for i, sc := range scoredList { + usr, err := s.store.GetUserByID(context.Background(), sc.userID) + if err != nil { + continue + } + entries = append(entries, lbEntry{ + UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1, + AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto, + }) + } + + // my position + myRank := -1 + var myXP float64 + if s.redis != nil { + rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() + if err == nil { + myRank = int(rank) + 1 + } + score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() + if err == nil { + myXP = score + } + } + + if entries == nil { + entries = []lbEntry{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "week": service.ISOWeek(time.Now()), + "entries": entries, + "me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent}, + }) +} diff --git a/backend/internal/api/learning.go b/backend/internal/api/learning.go new file mode 100644 index 0000000..d4cb4ff --- /dev/null +++ b/backend/internal/api/learning.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + + "pargar/backend/internal/observability" +) + +func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) { + ctx, span := observability.StartSpan(r.Context(), "admin.learning.overview") + defer span.End() + o, err := s.store.LearningOverview(ctx) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری نمای یادگیری ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"overview": o}) +} + +func (s *Server) handleLearningStudents(w http.ResponseWriter, r *http.Request) { + ctx, span := observability.StartSpan(r.Context(), "admin.learning.students") + defer span.End() + q := r.URL.Query().Get("q") + rows, err := s.store.ListStudentLearning(ctx, q) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"students": rows}) +} + +func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) { + ctx, span := observability.StartSpan(r.Context(), "admin.learning.user") + defer span.End() + id := routeID(r, "id") + detail, err := s.store.UserLearningDetail(ctx, id) + if err != nil { + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"learning": detail}) +} diff --git a/backend/internal/api/media_sign.go b/backend/internal/api/media_sign.go new file mode 100644 index 0000000..bb27298 --- /dev/null +++ b/backend/internal/api/media_sign.go @@ -0,0 +1,96 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" +) + +func (s *Server) signMediaURL(key string) string { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" { + return "" + } + exp := time.Now().Add(s.cfg.MediaURLTTL).Unix() + sig := mediaHMAC(s.cfg.JWTSecret, key, exp) + return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s", + s.cfg.PublicURL, + url.PathEscape(key), + exp, + sig, + ) +} + +func (s *Server) signExistingMediaURL(raw string) string { + if raw == "" { + return "" + } + key := mediaKeyFromURL(raw) + if key == "" { + return raw + } + return s.signMediaURL(key) +} + +func mediaKeyFromURL(raw string) string { + if i := strings.Index(raw, "/media/"); i >= 0 { + rest := raw[i+len("/media/"):] + if q := strings.Index(rest, "?"); q >= 0 { + rest = rest[:q] + } + key, err := url.PathUnescape(rest) + if err != nil { + return "" + } + return strings.TrimPrefix(key, "/") + } + return strings.TrimPrefix(raw, "/") +} + +func mediaHMAC(secret, key string, exp int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = fmt.Fprintf(mac, "%s:%d", key, exp) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) verifyMediaSignature(key, expStr, sig string) bool { + if key == "" || expStr == "" || sig == "" { + return false + } + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil || exp < time.Now().Unix() { + return false + } + expected := mediaHMAC(s.cfg.JWTSecret, key, exp) + return hmac.Equal([]byte(expected), []byte(sig)) +} + +func (s *Server) signUserMedia(u *models.User) { + if u == nil || u.AvatarPhoto == "" { + return + } + key := mediaKeyFromURL(u.AvatarPhoto) + if !mediaKeyOwnedByUser(key, u.ID) { + // Refuse to mint signed URLs for foreign lesson/video keys stored as "avatar". + u.AvatarPhoto = "" + return + } + u.AvatarPhoto = s.signExistingMediaURL(u.AvatarPhoto) +} + +// mediaKeyOwnedByUser reports whether key is a user-uploaded avatar/chat object. +func mediaKeyOwnedByUser(key string, userID int64) bool { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" || strings.Contains(key, "..") { + return false + } + uid := strconv.FormatInt(userID, 10) + return strings.HasPrefix(key, "avatars/"+uid+"-") || strings.HasPrefix(key, "chats/"+uid+"-") +} diff --git a/backend/internal/api/media_sign_test.go b/backend/internal/api/media_sign_test.go new file mode 100644 index 0000000..4b76438 --- /dev/null +++ b/backend/internal/api/media_sign_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "testing" + "time" + + "pargar/backend/internal/config" +) + +func TestMediaSignatureRoundTrip(t *testing.T) { + s := &Server{cfg: &config.Config{JWTSecret: "test-secret-at-least-32-chars-long!!", PublicURL: "http://localhost", MediaURLTTL: time.Hour}} + url := s.signMediaURL("videos/lesson-1.mp4") + if url == "" || !containsAll(url, "/media/", "exp=", "sig=") { + t.Fatalf("bad signed url: %s", url) + } + key := mediaKeyFromURL(url) + if key != "videos/lesson-1.mp4" { + t.Fatalf("key extract: %q", key) + } + // parse query manually + exp := "" + sig := "" + for _, part := range splitQuery(url) { + if len(part) > 4 && part[:4] == "exp=" { + exp = part[4:] + } + if len(part) > 4 && part[:4] == "sig=" { + sig = part[4:] + } + } + if !s.verifyMediaSignature(key, exp, sig) { + t.Fatal("signature should verify") + } + if s.verifyMediaSignature(key, "1", sig) { + t.Fatal("expired should fail") + } +} + +func containsAll(s string, parts ...string) bool { + for _, p := range parts { + if !containsStr(s, p) { + return false + } + } + return true +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +func splitQuery(u string) []string { + i := indexOf(u, "?") + if i < 0 { + return nil + } + q := u[i+1:] + var parts []string + start := 0 + for j := 0; j <= len(q); j++ { + if j == len(q) || q[j] == '&' { + parts = append(parts, q[start:j]) + start = j + 1 + } + } + return parts +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go new file mode 100644 index 0000000..36c545f --- /dev/null +++ b/backend/internal/api/middleware.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + "pargar/backend/internal/models" + "pargar/backend/internal/service" +) + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxMeta +) + +// requestMeta is shared across middleware and auth so access logs can include user_id. +type requestMeta struct { + UserID int64 +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// requireAuth resolves the caller from the Bearer token and stores the user in context. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := s.Authenticate(r) + if err != nil { + writeErrCode(w, http.StatusUnauthorized, CodeUnauthorized, err.Error()) + return + } + if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil { + meta.UserID = u.ID + } + ctx := context.WithValue(r.Context(), ctxUser, u) + next(w, r.WithContext(ctx)) + } +} + +// requireRole wraps requireAuth and enforces a minimum role. +func (s *Server) requireRole(roles ...models.Role) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + writeErr(w, http.StatusUnauthorized, "نیاز به ورود دارید") + return + } + allowed := false + for _, role := range roles { + if u.Role == role { + allowed = true + break + } + } + if !allowed { + writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید") + return + } + next(w, r) + }) + } +} + +func currentUser(r *http.Request) *models.User { + u, _ := r.Context().Value(ctxUser).(*models.User) + return u +} + +func (s *Server) lockedEnforce(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u != nil && u.IsLocked && !u.Role.IsStaff() { + writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error()) + return + } + next(w, r) + } +} + +// bodyJSON decodes a JSON request body into v. +func bodyJSON(r *http.Request, v any) error { + dec := json.NewDecoder(r.Body) + return dec.Decode(v) +} diff --git a/backend/internal/api/notifications.go b/backend/internal/api/notifications.go new file mode 100644 index 0000000..f0fcf60 --- /dev/null +++ b/backend/internal/api/notifications.go @@ -0,0 +1,97 @@ +package api + +import ( + "net/http" + "strconv" + + "pargar/backend/internal/models" +) + +func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + limit := 30 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + items, err := s.store.ListNotifications(r.Context(), u.ID, limit, offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری اعلان‌ها ممکن نشد") + return + } + unread, _ := s.store.UnreadCount(r.Context(), u.ID) + if items == nil { + items = []models.Notification{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "notifications": items, + "unread": unread, + }) +} + +func (s *Server) handleUnreadCount(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + n, err := s.store.UnreadCount(r.Context(), u.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "شمارش ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"unread": n}) +} + +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + if err := s.store.MarkRead(r.Context(), u.ID, id); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی اعلان ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if err := s.store.MarkAllRead(r.Context(), u.ID); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی اعلان‌ها ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleGetPrefs(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + prefs, err := s.store.GetPrefs(r.Context(), u.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری تنظیمات ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"prefs": prefs}) +} + +func (s *Server) handlePutPrefs(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + var req models.NotificationPrefs + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + p := &models.NotificationPrefs{ + UserID: u.ID, + Progress: req.Progress, + Gamification: req.Gamification, + Mentor: req.Mentor, + Event: req.Event, + } + if err := s.store.UpdatePrefs(r.Context(), p); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ تنظیمات ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"prefs": p}) +} diff --git a/backend/internal/api/pagination.go b/backend/internal/api/pagination.go new file mode 100644 index 0000000..90f4a53 --- /dev/null +++ b/backend/internal/api/pagination.go @@ -0,0 +1,66 @@ +package api + +import ( + "net/http" + "reflect" + "strconv" +) + +const ( + defaultPageSize = 10 + maxPageSize = 100 +) + +type pageParams struct { + Page int + PageSize int + Offset int +} + +// parsePageParams reads page + pageSize (or limit) from the query string. +// Defaults: page=1, pageSize=10. pageSize is capped at 100. +func parsePageParams(r *http.Request) pageParams { + page := 1 + pageSize := defaultPageSize + + if v := r.URL.Query().Get("page"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + page = n + } + } + if v := r.URL.Query().Get("pageSize"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + pageSize = n + } + } else if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + pageSize = n + } + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + + return pageParams{ + Page: page, + PageSize: pageSize, + Offset: (page - 1) * pageSize, + } +} + +func writePage(w http.ResponseWriter, items any, total int64, p pageParams) { + if items == nil { + items = []any{} + } else { + v := reflect.ValueOf(items) + if v.Kind() == reflect.Slice && v.IsNil() { + items = reflect.MakeSlice(v.Type(), 0, 0).Interface() + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "total": total, + "page": p.Page, + "pageSize": p.PageSize, + }) +} diff --git a/backend/internal/api/prod_hardening_test.go b/backend/internal/api/prod_hardening_test.go new file mode 100644 index 0000000..afcd424 --- /dev/null +++ b/backend/internal/api/prod_hardening_test.go @@ -0,0 +1,115 @@ +package api_test + +import ( + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestRouteLabelRedactsExportAndInviteTokens(t *testing.T) { + // Exercised via access path: forged large token must not break handlers, + // and single-use / captcha flows remain healthy. Direct label coverage lives + // in package api (route_label_test.go). + e := setup(t) + code, _, _ := e.doRaw(t, "GET", "/api/exports/chats/super-secret-export-token-value", "", nil) + if code != http.StatusUnauthorized { + t.Fatalf("bogus export token: want 401 got %d", code) + } +} + +func TestMetricsRequiresTokenOutsideDevOpen(t *testing.T) { + e := setup(t) + // DevMode=true in tests → metrics open without token. + code, _, _ := e.doRaw(t, "GET", "/metrics", "", nil) + if code != http.StatusOK { + t.Fatalf("dev metrics: want 200 got %d", code) + } +} + +func TestWatchAntiCheatCapsForgedDelta(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Cheat Watch", "cheat-watch@test.dev") + + // Wait so wall-clock cap is tight, then claim a huge delta. + time.Sleep(200 * time.Millisecond) + code, body := e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{ + "position": 90, "delta": 999, "seq": 1, + }) + if code != http.StatusOK { + t.Fatalf("heartbeat: %d %v", code, body) + } + watched, _ := body["watchedSeconds"].(float64) + // maxHeartbeatDelta is 15s; with ~0.2s wall clock + slack, credit must stay well under 20s. + if watched > 20 { + t.Fatalf("forged delta credited too much watch time: %v", watched) + } + if watched <= 0 { + t.Fatalf("expected some credit, got %v", watched) + } + + // Replay same seq — must not double-credit. + prev := watched + code, body = e.do(t, "POST", "/api/lessons/1/heartbeat", token, map[string]any{ + "position": 91, "delta": 9, "seq": 1, + }) + if code != http.StatusOK { + t.Fatalf("replay heartbeat: %d %v", code, body) + } + watched, _ = body["watchedSeconds"].(float64) + if watched != prev { + t.Fatalf("seq replay credited again: before=%v after=%v", prev, watched) + } +} + +func TestRegisterRaceSamePhone(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09127770001") + + const n = 8 + bodies := make([]map[string]any, n) + for i := 0; i < n; i++ { + bodies[i] = registerBody(t, e, "09127770001", + "race-phone-"+itoa(int64(i+1))+"@test.dev", + "raceph"+itoa(int64(i+1))) + } + + var okCount atomic.Int32 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + code, _ := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Real-IP": "203.0.113." + itoa(int64(40+i)), + }, bodies[i]) + if code == http.StatusCreated { + okCount.Add(1) + } + }() + } + wg.Wait() + if okCount.Load() != 1 { + t.Fatalf("expected exactly one successful register for same phone, got %d", okCount.Load()) + } +} + +func TestRegisterRequiresCaptcha(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09127770099") + body := map[string]any{ + "name": "بدون کپچا", "email": "nocap@test.dev", "username": "nocapuser", + "password": "password123", "passwordConfirm": "password123", "phone": "09127770099", + "securityQuestion": "رنگ؟", "securityAnswer": "آبی", + } + code, res := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Real-IP": "203.0.113.199", + }, body) + if code != http.StatusBadRequest { + t.Fatalf("register without captcha: want 400 got %d %v", code, res) + } +} diff --git a/backend/internal/api/quiz.go b/backend/internal/api/quiz.go new file mode 100644 index 0000000..fa9ce70 --- /dev/null +++ b/backend/internal/api/quiz.go @@ -0,0 +1,369 @@ +package api + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/service" + "pargar/backend/internal/store" +) + +func (s *Server) handleQuiz(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + pg, _ := s.store.GetProgress(r.Context(), u.ID, id) + if pg == nil || !pg.QuizUnlocked { + writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error()) + return + } + questions, err := s.store.ListQuestions(r.Context(), id, false) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پرسش‌ها ممکن نشد") + return + } + views := make([]models.QuizQuestionView, 0, len(questions)) + for _, q := range questions { + views = append(views, models.QuizQuestionView{ID: q.ID, Position: q.Position, Question: q.Question, Options: q.Options}) + } + lastAttempt, _ := s.store.LastAttempt(r.Context(), u.ID, id) + writeJSON(w, http.StatusOK, map[string]any{ + "lessonID": id, + "duration": lesson.DurationSeconds, + "questions": views, + "lastAttempt": lastAttempt, + "passed": pg.PassedQuiz, + "quizStatus": attemptStatus(lastAttempt), + }) +} + +type quizSubmitRequest struct { + Answers []int `json:"answers"` +} + +func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) { + ctx, span := observability.StartSpan(r.Context(), "quiz.submit") + defer span.End() + r = r.WithContext(ctx) + + u := currentUser(r) + id := routeID(r, "id") + + // refresh any regenerated hearts before consuming + u, _ = s.hearts.RefreshHearts(r.Context(), u) + + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + pg, err := s.store.GetProgress(r.Context(), u.ID, id) + if err != nil || !pg.QuizUnlocked { + writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error()) + return + } + + var req quizSubmitRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "پاسخ‌ها نامعتبر هستند") + return + } + if len(req.Answers) == 0 { + writeErr(w, http.StatusBadRequest, "هیچ پاسخی ارسال نشده است") + return + } + + questions, err := s.store.ListQuestions(r.Context(), id, true) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پرسش‌ها ممکن نشد") + return + } + if len(questions) == 0 { + writeErr(w, http.StatusBadRequest, "این درس پرسشی ندارد") + return + } + + // grade server-side; answer keys never leave the server + correct, wrong := 0, 0 + for i, q := range questions { + if i >= len(req.Answers) || req.Answers[i] < 0 { + wrong++ + continue + } + if req.Answers[i] >= len(q.Options) { + wrong++ + continue + } + if req.Answers[i] == q.AnswerIndex { + correct++ + } else { + wrong++ + } + } + total := len(questions) + passed := correct == total // strict: every answer must be correct + + // record attempt + attempt, err := s.store.CreateAttempt(r.Context(), u.ID, id, req.Answers) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت تلاش ممکن نشد") + return + } + status := "failed" + if passed { + status = "passed" + } + if err := s.store.FinalizeAttempt(r.Context(), attempt.ID, correct, total, float64(correct)/float64(total)*100, status, wrong); err != nil { + observability.L().Error("finalize attempt", "error", err, "attempt_id", attempt.ID) + writeErr(w, http.StatusInternalServerError, "نهایی‌سازی تلاش ممکن نشد") + return + } + + activeUser := u + heartsLost := 0 + xpEarned := 0 + wasLocked := false + + if passed { + firstPass, claimErr := s.store.ClaimFirstQuizPass(r.Context(), u.ID, id) + if claimErr != nil { + writeErr(w, http.StatusInternalServerError, "ثبت قبولی درس ممکن نشد") + return + } + if firstPass { + observability.Activity("lesson.passed", "user_id", u.ID, "lesson_id", id, "xp", lesson.XPReward) + xpEarned = lesson.XPReward + activeUser, err = s.rewardXP(r.Context(), u, xpEarned, "quiz", id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "اعطای امتیاز ممکن نشد") + return + } + if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil { + observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) + go func() { + _ = s.notify.Notify(context.Background(), u.ID, "progress", "certificate_issued", + "گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil) + }() + } + go func() { + s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent) + _ = s.notifyUnlockedLessons(u.ID, id) + }() + } + } else if pg.PassedQuiz { + // Already cleared — practice attempts do not burn hearts. + activeUser = u + } else { + // consume hearts for wrong answers + activeUser, heartsLost, err = s.hearts.ConsumeHearts(r.Context(), u, wrong) + if err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی جان‌ها ممکن نشد") + return + } + if activeUser.IsLocked && !u.IsLocked { + wasLocked = true + observability.Lockouts.Inc() + observability.Activity("account.locked", "user_id", u.ID, "lesson_id", id, "reason", "hearts") + } + go func(wasLocked bool) { + if wasLocked { + _ = s.notify.Notify(context.Background(), u.ID, "progress", "account_locked", + "حساب محدود شد", "جان‌های شما تمام شده است. دسترسی به حساب تا بررسی متصدی محدود می‌شود.", "/lockout", nil) + } else if heartsLost > 0 { + _ = s.notify.Notify(context.Background(), u.ID, "progress", "heart_lost", + "یک جان از دست رفت", "یکی از جان‌های شما کم شد. ادامه دهید!", "/unwrap", nil) + } + }(wasLocked) + } + + resultLabel := "fail" + if passed { + resultLabel = "pass" + } + observability.QuizSubmits.WithLabelValues(resultLabel).Inc() + observability.Activity("quiz.submit", + "user_id", u.ID, + "lesson_id", id, + "passed", passed, + "correct", correct, + "total", total, + "hearts_lost", heartsLost, + "hearts_left", activeUser.Hearts, + "xp_earned", xpEarned, + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "passed": passed, + "correctCount": correct, + "total": total, + "heartsLost": heartsLost, + "heartsLeft": activeUser.Hearts, + "xpEarned": xpEarned, + "locked": activeUser.IsLocked, + "wasLocked": wasLocked, + "streak": activeUser.StreakCurrent, + }) +} + +// rewardXP applies XP atomically: user, xp_events ledger, weekly leaderboard (DB + Redis), streak. +func (s *Server) rewardXP(ctx context.Context, u *models.User, amount int, source string, refID int64) (*models.User, error) { + tx, err := s.store.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if err := s.store.AddXPEvent(ctx, tx, u.ID, amount, source, refID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + if err := s.store.UpdateLeaderboardRow(ctx, service.ISOWeek(time.Now()), u.ID, amount); err != nil { + return nil, err + } + if err := s.store.AddChallengeProgress(ctx, u.ID, amount); err != nil { + return nil, err + } + if s.redis != nil { + key := "lb:" + service.ISOWeek(time.Now()) + s.redis.ZIncrBy(ctx, key, float64(amount), strconv.FormatInt(u.ID, 10)) + s.redis.ZIncrBy(ctx, key, 0, strconv.FormatInt(u.ID, 10)) // ensure membership + } + user, err := s.store.AddUserXP(ctx, u.ID, amount) + if err != nil { + return nil, err + } + updated, err := service.UpdateStreak(ctx, s.store, user) + if err != nil { + return nil, err + } + return updated, nil +} + +func (s *Server) notifyQuizPassed(userID int64, lessonTitle string, lessonID int64, streak int) { + body := "آفرین! درس «" + lessonTitle + "» را با موفقیت گذراندید." + if streak > 0 && streak%7 == 0 { + body = "آفرین! زنجیرهٔ فعالیت شما به " + strconv.Itoa(streak) + " روز رسید." + } + _ = s.notify.Notify(context.Background(), userID, "gamification", "quiz_passed", + "آزمون را قبول شدید", body, "/cap?lesson="+strconv.FormatInt(lessonID, 10), nil) +} + +// notifyUnlockedLessons notifies about lessons whose prerequisite just got passed. +func (s *Server) notifyUnlockedLessons(userID, lessonID int64) error { + lessons, err := s.store.ListLessons(context.Background(), true) + if err != nil { + return err + } + for _, l := range lessons { + if l.RequiresLessonID != nil && *l.RequiresLessonID == lessonID { + _ = s.notify.Notify(context.Background(), userID, "progress", "lesson_unlocked", + "درس جدید باز شد", "درس «"+l.Title+"» اکنون در دسترس است.", "/cap?lesson="+strconv.FormatInt(l.ID, 10), nil) + } + } + return nil +} + +// ---- quiz result endpoint ---- + +func (s *Server) handleQuizResult(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + at, err := s.store.LastAttempt(r.Context(), u.ID, id) + if err != nil { + if err == store.ErrNotFound { + writeErr(w, http.StatusNotFound, "هنوز تلاشی ثبت نشده است") + return + } + writeErr(w, http.StatusInternalServerError, "بارگذاری نتیجه ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "correctCount": at.CorrectCount, + "total": at.Total, + "scorePct": at.ScorePct, + "status": at.Status, + "passedAt": at.PassedAt, + "createdAt": at.CreatedAt, + }) +} + +// handleRequestReset does not provide a self-service unlock path. +func (s *Server) handleRequestReset(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusForbidden, "بازنشانی مسیر فقط با اقدام متصدی ممکن است") +} + +// handleRequestUnlock queues a staff review request (does not unlock the account). +func (s *Server) handleRequestUnlock(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u.Role != models.RoleStudent { + writeErr(w, http.StatusBadRequest, "فقط دانشجو می‌تواند درخواست رفع قفل بدهد") + return + } + if !u.IsLocked { + writeErr(w, http.StatusConflict, "حساب شما قفل نیست") + return + } + var req struct { + Note string `json:"note"` + } + _ = bodyJSON(r, &req) + req.Note = strings.TrimSpace(req.Note) + if len(req.Note) > 500 { + writeErr(w, http.StatusBadRequest, "یادداشت نباید بیش از ۵۰۰ نویسه باشد") + return + } + if existing, err := s.store.LatestOpenUnlockRequest(r.Context(), u.ID, 12*time.Hour); err == nil && existing != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "request": existing, + "message": "درخواست قبلی شما هنوز در صف بررسی است", + }) + return + } + ur, err := s.store.CreateUnlockRequest(r.Context(), u.ID, req.Note) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد") + return + } + staff, _ := s.store.ListMentors(r.Context()) + body := u.Name + " درخواست رفع قفل حساب داده است." + if req.Note != "" { + body += " یادداشت: " + req.Note + } + go func() { + for _, m := range staff { + _ = s.notify.Notify(context.Background(), m.ID, "mentor", "unlock_request", + "درخواست رفع قفل", body, "/admin", map[string]any{"userId": u.ID, "requestId": ur.ID}) + } + }() + observability.Activity("account.unlock_requested", "user_id", u.ID, "request_id", ur.ID) + writeJSON(w, http.StatusCreated, map[string]any{ + "ok": true, + "request": ur, + "message": "درخواست برای تیم ارسال شد. می‌توانید همزمان با منتور هم گفتگو کنید.", + }) +} diff --git a/backend/internal/api/rate_limit.go b/backend/internal/api/rate_limit.go new file mode 100644 index 0000000..a495e33 --- /dev/null +++ b/backend/internal/api/rate_limit.go @@ -0,0 +1,301 @@ +package api + +import ( + "context" + "net/http" + "strconv" + "time" +) + +const ( + loginBaseLimit int64 = 20 + refreshRateLimit int64 = 60 + // apiGlobalLimitDefault is generous for normal LMS polling/navigation (~4 req/s). + apiGlobalLimitDefault int64 = 240 + uploadRateLimit int64 = 20 // per-user chat/avatar uploads per minute + wsHandshakeLimit int64 = 30 // per-IP WS upgrades per minute + chatExportRateLimit int64 = 10 // per-IP external chat-export downloads per minute + rateLimitWindow = time.Minute + authFailTTL = 15 * time.Minute + apiGlobalRateMsg = "تعداد درخواست‌ها زیاد است؛ لطفاً کمی صبر کنید" + authRateMsg = "تعداد تلاش‌ها بیش از حد مجاز است؛ کمی بعد دوباره تلاش کنید" +) + +// apiGlobalLimit is the per-IP budget for all /api/* routes (except /api/health). +// Mutable so tests can lower it without hammering Redis. +var apiGlobalLimit = apiGlobalLimitDefault + +// wsHandshakeLimitVar is mutable for tests. +var wsHandshakeLimitVar = wsHandshakeLimit + +// SetAPIGlobalLimitForTest overrides the global /api budget and returns the previous value. +func SetAPIGlobalLimitForTest(limit int64) (previous int64) { + previous = apiGlobalLimit + apiGlobalLimit = limit + return previous +} + +// SetWSHandshakeLimitForTest overrides the per-IP WS upgrade budget. +func SetWSHandshakeLimitForTest(limit int64) (previous int64) { + previous = wsHandshakeLimitVar + wsHandshakeLimitVar = limit + return previous +} + +// effectiveAuthLimit tightens the per-IP request budget after recent failures. +// Healthy clients (no failures) keep the full baseline. +func effectiveAuthLimit(base, failCount int64) int64 { + switch { + case failCount <= 0: + return base + case failCount <= 2: + if base > 15 { + return 15 + } + return base + case failCount <= 5: + return 8 + case failCount <= 9: + return 4 + default: + return 2 + } +} + +func authFailKey(scope, ip string) string { + return "pargar:auth-fail:" + scope + ":" + ip +} + +func authRateKey(scope, ip string) string { + return "pargar:auth-rate:" + scope + ":" + ip +} + +func apiRateKey(ip string) string { + return "pargar:api-rate:" + ip +} + +func uploadRateKey(userID int64) string { + return "pargar:upload-rate:" + strconv.FormatInt(userID, 10) +} + +func wsRateKey(ip string) string { + return "pargar:ws-rate:" + ip +} + +func chatExportRateKey(ip string) string { + return "pargar:chat-export-rate:" + ip +} + +// ipRateLimit caps a public surface per client IP (e.g. external chat export). +func (s *Server) ipRateLimit(limit int64, keyFn func(string) string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.redis == nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + ip := clientAddress(r) + if ip == "" { + next(w, r) + return + } + key := keyFn(ip) + count, err := s.redis.Incr(r.Context(), key).Result() + if err != nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + if count == 1 { + _ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err() + } + if count > limit { + w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second))) + writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg) + return + } + next(w, r) + } +} + +func (s *Server) authFailCount(ctx context.Context, scope, ip string) int64 { + if s.redis == nil { + return 0 + } + n, err := s.redis.Get(ctx, authFailKey(scope, ip)).Int64() + if err != nil { + return 0 + } + return n +} + +func (s *Server) recordAuthFailure(ctx context.Context, scope, ip string) { + if s.redis == nil || ip == "" { + return + } + key := authFailKey(scope, ip) + n, err := s.redis.Incr(ctx, key).Result() + if err != nil { + return + } + if n == 1 { + _ = s.redis.Expire(ctx, key, authFailTTL).Err() + } +} + +func (s *Server) clearAuthFailures(ctx context.Context, scope, ip string) { + if s.redis == nil || ip == "" { + return + } + // Drop both the failure streak and the short request window so a healthy + // success restores full budget instead of leaving residual pressure. + _ = s.redis.Del(ctx, authFailKey(scope, ip), authRateKey(scope, ip)).Err() +} + +func (s *Server) authRateLimit(limit int64, scope string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.redis == nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + ip := clientAddress(r) + fails := s.authFailCount(r.Context(), scope, ip) + effective := effectiveAuthLimit(limit, fails) + key := authRateKey(scope, ip) + count, err := s.redis.Incr(r.Context(), key).Result() + if err != nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + if count == 1 { + _ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err() + } + if count > effective { + w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second))) + writeErr(w, http.StatusTooManyRequests, authRateMsg) + return + } + next(w, r) + } +} + +// globalAPIRateLimit applies a generous per-IP sliding-window budget to /api/*. +// It never revokes tokens or locks accounts — only returns 429 + Retry-After. +// /api/ws is excluded: upgrades must not share the HTTP polling budget, and +// reconnect storms should not burn the same counter as REST calls. +func (s *Server) globalAPIRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/ws" { + next.ServeHTTP(w, r) + return + } + if s.redis == nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next.ServeHTTP(w, r) + return + } + ip := clientAddress(r) + if ip == "" { + next.ServeHTTP(w, r) + return + } + key := apiRateKey(ip) + count, err := s.redis.Incr(r.Context(), key).Result() + if err != nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next.ServeHTTP(w, r) + return + } + if count == 1 { + _ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err() + } + if count > apiGlobalLimit { + w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second))) + writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg) + return + } + next.ServeHTTP(w, r) + }) +} + +// perUserRateLimit caps authenticated abuse surfaces (uploads) per user id. +func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + writeErr(w, http.StatusUnauthorized, "نیاز به ورود دارید") + return + } + if s.redis == nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + key := uploadRateKey(u.ID) + if scope != "upload" { + key = "pargar:user-rate:" + scope + ":" + strconv.FormatInt(u.ID, 10) + } + count, err := s.redis.Incr(r.Context(), key).Result() + if err != nil { + if !s.cfg.DevMode { + writeErr(w, http.StatusServiceUnavailable, "محدودیت نرخ در دسترس نیست") + return + } + next(w, r) + return + } + if count == 1 { + _ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err() + } + if count > limit { + w.Header().Set("Retry-After", strconv.Itoa(int(rateLimitWindow/time.Second))) + writeErr(w, http.StatusTooManyRequests, apiGlobalRateMsg) + return + } + next(w, r) + } +} + +// allowWSHandshake enforces a per-IP budget on WebSocket upgrades. +// Returns false when the caller should receive 429 (already written by caller). +func (s *Server) allowWSHandshake(r *http.Request) bool { + if s.redis == nil { + return s.cfg.DevMode + } + ip := clientAddress(r) + if ip == "" { + return true + } + key := wsRateKey(ip) + count, err := s.redis.Incr(r.Context(), key).Result() + if err != nil { + return s.cfg.DevMode + } + if count == 1 { + _ = s.redis.Expire(r.Context(), key, rateLimitWindow).Err() + } + return count <= wsHandshakeLimitVar +} diff --git a/backend/internal/api/rate_limit_test.go b/backend/internal/api/rate_limit_test.go new file mode 100644 index 0000000..b39f256 --- /dev/null +++ b/backend/internal/api/rate_limit_test.go @@ -0,0 +1,104 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "testing" + + "pargar/backend/internal/api" +) + +func TestGlobalAPIRateLimit(t *testing.T) { + e := setup(t) + prev := api.SetAPIGlobalLimitForTest(5) + t.Cleanup(func() { api.SetAPIGlobalLimitForTest(prev) }) + + hitLimit := false + var retryAfter string + for i := 0; i < 12; i++ { + req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/register-status", nil) + req.Header.Set("X-Forwarded-For", "198.51.100.50") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + if body["error"] != "تعداد درخواست‌ها زیاد است؛ لطفاً کمی صبر کنید" { + t.Fatalf("unexpected message: %v", body) + } + retryAfter = resp.Header.Get("Retry-After") + if retryAfter == "" { + t.Fatal("expected Retry-After header") + } + hitLimit = true + break + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("request %d: expected 200 or 429, got %d %v", i+1, resp.StatusCode, body) + } + } + if !hitLimit { + t.Fatal("expected global API rate limit") + } + + // Health must stay excluded from the global bucket (docker probes). + for i := 0; i < 20; i++ { + req, _ := http.NewRequest("GET", e.server.URL+"/api/health", nil) + req.Header.Set("X-Forwarded-For", "198.51.100.50") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("health: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("health must not be rate-limited, got %d", resp.StatusCode) + } + } + + // A different IP still has budget — proves the limit is per-IP, not global process-wide. + code, body := e.doHeaders(t, "GET", "/api/auth/register-status", "", map[string]string{ + "X-Forwarded-For": "198.51.100.51", + }, nil) + if code != http.StatusOK { + t.Fatalf("other IP should succeed, got %d %v", code, body) + } +} + +func TestGlobalRateLimitDoesNotClearAuthTokens(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + prev := api.SetAPIGlobalLimitForTest(3) + t.Cleanup(func() { api.SetAPIGlobalLimitForTest(prev) }) + + // Burn the budget with authenticated calls from a fixed IP. + for i := 0; i < 8; i++ { + req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-For", "203.0.113.90") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("me: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + break + } + } + + // After 429, the same token must still authenticate once the counter is cleared — + // proving 429 did not revoke the session server-side. + if err := e.rdb.FlushDB(t.Context()).Err(); err != nil { + t.Fatalf("flush: %v", err) + } + code, body := e.doHeaders(t, "GET", "/api/auth/me", token, map[string]string{ + "X-Forwarded-For": "203.0.113.90", + }, nil) + if code != http.StatusOK { + t.Fatalf("token should still work after rate limit, got %d %v", code, body) + } +} diff --git a/backend/internal/api/recovery.go b/backend/internal/api/recovery.go new file mode 100644 index 0000000..1035774 --- /dev/null +++ b/backend/internal/api/recovery.go @@ -0,0 +1,16 @@ +package api + +import ( + "net/http" +) + +// Public self-service password recovery is disabled for students. +// Staff reset passwords from the admin panel (requires the account security answer). + +func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است") +} + +func (s *Server) handleRecoveryReset(w http.ResponseWriter, r *http.Request) { + writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است") +} diff --git a/backend/internal/api/recovery_test.go b/backend/internal/api/recovery_test.go new file mode 100644 index 0000000..824803d --- /dev/null +++ b/backend/internal/api/recovery_test.go @@ -0,0 +1,80 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" +) + +func TestPasswordRecoveryDisabled(t *testing.T) { + e := setup(t) + _, _ = register(t, e, "Recovery User", "recover-me@test.dev") + + id, answer := fetchCaptcha(t, e) + code, body := e.do(t, "POST", "/api/auth/recovery/challenge", "", map[string]any{ + "login": "recover-me@test.dev", "challengeId": id, "captchaAnswer": answer, + }) + if code != http.StatusNotFound { + t.Fatalf("challenge should be disabled: want 404 got %d %v", code, body) + } + + id, answer = fetchCaptcha(t, e) + code, body = e.do(t, "POST", "/api/auth/recovery/reset", "", map[string]any{ + "login": "recover-me@test.dev", "securityAnswer": "rex", + "newPassword": "NewPassw0rd!", "challengeId": id, "captchaAnswer": answer, + }) + if code != http.StatusNotFound { + t.Fatalf("reset should be disabled: want 404 got %d %v", code, body) + } +} + +func TestSessionCookieOnLogin(t *testing.T) { + e := setup(t) + _, _ = register(t, e, "Cookie User", "cookie-user@test.dev") + code, body, cookies := e.doWithCookies(t, "POST", "/api/auth/login", "", loginBody(t, e, "cookie-user@test.dev", "password123"), nil) + if code != http.StatusOK { + t.Fatalf("login: %d %v", code, body) + } + var access string + for _, c := range cookies { + if c.Name == "pargar_access" { + access = c.Value + } + } + if access == "" { + t.Fatalf("missing pargar_access cookie: %v", cookies) + } + code, body, _ = e.doWithCookies(t, "GET", "/api/auth/me", "", nil, []*http.Cookie{ + {Name: "pargar_access", Value: access}, + }) + if code != http.StatusOK { + t.Fatalf("me via cookie: %d %v", code, body) + } +} + +func (e *testEnv) doWithCookies(t *testing.T, method, path, bearer string, body any, cookies []*http.Cookie) (int, map[string]any, []*http.Cookie) { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req, _ := http.NewRequest(method, e.server.URL+path, &buf) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out, resp.Cookies() +} diff --git a/backend/internal/api/register.go b/backend/internal/api/register.go new file mode 100644 index 0000000..3e4c480 --- /dev/null +++ b/backend/internal/api/register.go @@ -0,0 +1,180 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +func (s *Server) settingTruthy(r *http.Request, key string, defaultTrue bool) bool { + v, err := s.store.GetSetting(r.Context(), key) + if err != nil || strings.TrimSpace(v) == "" { + return defaultTrue + } + v = strings.TrimSpace(v) + return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") +} + +func (s *Server) registrationEnabled(r *http.Request) bool { + return s.settingTruthy(r, "registration_enabled", false) +} + +func (s *Server) registrationRequireWhitelist(r *http.Request) bool { + // Default ON: public signup must use an available whitelist phone. + return s.settingTruthy(r, "registration_require_whitelist", true) +} + +func (s *Server) handleRegisterStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": s.registrationEnabled(r), + "requireWhitelist": s.registrationRequireWhitelist(r), + }) +} + +type registerRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` + PasswordConfirm string `json:"passwordConfirm"` + Phone string `json:"phone"` + SecurityQuestion string `json:"securityQuestion"` + SecurityAnswer string `json:"securityAnswer"` + ChallengeID string `json:"challengeId"` + CaptchaAnswer string `json:"captchaAnswer"` +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if !s.registrationEnabled(r) { + writeErr(w, http.StatusForbidden, "ثبت‌نام عمومی فعلاً غیرفعال است") + return + } + + var req registerRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + + name := strings.TrimSpace(req.Name) + email := strings.ToLower(strings.TrimSpace(req.Email)) + username := strings.ToLower(strings.TrimSpace(req.Username)) + phone := normalizePhone(req.Phone) + secQ := strings.TrimSpace(req.SecurityQuestion) + secA := strings.TrimSpace(req.SecurityAnswer) + + if msg := validateStudentRegistrationFields( + name, email, username, phone, req.Password, req.PasswordConfirm, secQ, secA, + ); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } + if err := s.consumeCaptcha(r.Context(), req.ChallengeID, req.CaptchaAnswer); err != nil { + s.recordAuthFailure(r.Context(), "register", clientAddress(r)) + code, msg := captchaErrorMessage(err) + writeErr(w, code, msg) + return + } + if s.rejectIfBlacklisted(w, r, phone, "public", nil, username, email) { + s.recordAuthFailure(r.Context(), "register", clientAddress(r)) + return + } + + ip := clientAddress(r) + if ip == "" || ip == "unknown" { + writeErr(w, http.StatusBadRequest, "آدرس شبکه شناسایی نشد") + return + } + if taken, err := s.store.HasRegistrationIP(r.Context(), ip); err != nil { + writeErr(w, http.StatusInternalServerError, "بررسی محدودیت ثبت‌نام ممکن نشد") + return + } else if taken { + writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است") + return + } + + if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil { + writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی ایمیل ممکن نشد") + return + } + if _, err := s.store.GetUserByUsername(r.Context(), username); err == nil { + writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی شناسه کاربری ممکن نشد") + return + } + if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil { + writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است") + return + } else if err != store.ErrNotFound { + writeErr(w, http.StatusInternalServerError, "بررسی شماره تلفن ممکن نشد") + return + } + + hash, err := s.auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد") + return + } + answerHash, err := s.auth.HashPassword(strings.ToLower(secA)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد") + return + } + + user, err := s.store.RegisterPublicStudent( + r.Context(), + name, email, username, hash, phone, secQ, answerHash, ip, + s.registrationRequireWhitelist(r), + ) + if err != nil { + if errors.Is(err, store.ErrPhoneBlacklisted) { + s.recordAuthFailure(r.Context(), "register", ip) + writeErr(w, http.StatusForbidden, "این شماره مجاز به ثبت‌نام نیست") + return + } + if errors.Is(err, store.ErrRegistrationIPTaken) { + writeErr(w, http.StatusConflict, "از این شبکه قبلاً ثبت‌نام انجام شده است") + return + } + if errors.Is(err, store.ErrWhitelistNotAllowed) { + s.recordAuthFailure(r.Context(), "register", ip) + writeErr(w, http.StatusForbidden, "این شماره در فهرست مجاز نیست") + return + } + if errors.Is(err, store.ErrWhitelistConsumed) { + writeErr(w, http.StatusConflict, "این شماره قبلاً استفاده شده") + return + } + if store.IsUniqueViolation(err) { + writeErr(w, http.StatusConflict, "این اطلاعات قبلاً ثبت شده است") + return + } + writeErr(w, http.StatusInternalServerError, "ثبت‌نام ممکن نشد") + return + } + + access, refresh, err := s.auth.IssueForUser(r.Context(), user) + if err != nil { + writeErr(w, http.StatusInternalServerError, "صدور نشست ورود ممکن نشد") + return + } + if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil { + meta.UserID = user.ID + } + s.clearAuthFailures(r.Context(), "register", ip) + observability.Logins.WithLabelValues("ok").Inc() + observability.Activity("auth.register", "user_id", user.ID, "remote", ip) + s.signUserMedia(user) + s.setSessionCookies(w, access, refresh, s.cfg.RefreshTokenTTL) + writeJSON(w, http.StatusCreated, map[string]any{ + "accessToken": access, "refreshToken": refresh, "user": user, + }) +} diff --git a/backend/internal/api/register_test.go b/backend/internal/api/register_test.go new file mode 100644 index 0000000..0bcb869 --- /dev/null +++ b/backend/internal/api/register_test.go @@ -0,0 +1,307 @@ +package api_test + +import ( + "net/http" + "testing" +) + +func enableRegistration(t *testing.T, e *testEnv) { + t.Helper() + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ + "settings": map[string]string{"registration_enabled": "true"}, + }) + if code != http.StatusOK { + t.Fatalf("enable registration: %d %v", code, body) + } +} + +func whitelistPhones(t *testing.T, e *testEnv, phones ...string) { + t.Helper() + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": phones, + }) + if code != http.StatusOK { + t.Fatalf("whitelist import: %d %v", code, body) + } +} + +func registerBody(t *testing.T, e *testEnv, phone, email, username string) map[string]any { + t.Helper() + id, answer := fetchCaptcha(t, e) + return map[string]any{ + "name": "هنرجو تست", "email": email, "username": username, + "password": "password123", "passwordConfirm": "password123", "phone": phone, + "securityQuestion": "رنگ مورد علاقه؟", "securityAnswer": "آبی", + "challengeId": id, "captchaAnswer": answer, + } +} + +func TestRegisterPasswordMismatch(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110901") + + body := registerBody(t, e, "09121110901", "reg-mismatch@test.dev", "regmismatch") + body["passwordConfirm"] = "different99" + code, res := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.91", + }, body) + if code != http.StatusBadRequest { + t.Fatalf("expected bad request, got %d %v", code, res) + } + if res["error"] != "گذرواژه و تکرار آن یکسان نیستند" { + t.Fatalf("error: %v", res["error"]) + } +} + +func TestRegisterUsernameRejectsPersianAndSpaces(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110910", "09121110911", "09121110912") + + cases := []struct { + username string + phone string + ip string + }{ + {"کاربر", "09121110910", "203.0.113.201"}, + {"user name", "09121110911", "203.0.113.202"}, + {"1startswithnum", "09121110912", "203.0.113.203"}, + } + for _, c := range cases { + code, res := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": c.ip, + }, registerBody(t, e, c.phone, "reg-u"+c.phone+"@test.dev", c.username)) + if code != http.StatusBadRequest { + t.Fatalf("%q: expected 400, got %d %v", c.username, code, res) + } + if res["error"] != "شناسه باید با حرف انگلیسی شروع شود و ۳ تا ۳۲ نویسهٔ لاتین، عدد، نقطه، خط یا زیرخط باشد" { + t.Fatalf("%q error: %v", c.username, res["error"]) + } + } +} + +func TestRegisterUsernameAcceptsValid(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110920") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.220", + }, registerBody(t, e, "09121110920", "reg-uok@test.dev", "ahp.dev_1")) + if code != http.StatusCreated { + t.Fatalf("valid username rejected: %d %v", code, body) + } +} + +func TestRegisterInvalidEmailAndPhone(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110930") + + body := registerBody(t, e, "09121110930", "not-an-email", "emailevil") + code, res := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.230", + }, body) + if code != http.StatusBadRequest || res["error"] != "ایمیل نامعتبر است" { + t.Fatalf("email: %d %v", code, res) + } + + body = registerBody(t, e, "12345", "phone-bad@test.dev", "phonebad") + code, res = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.231", + }, body) + if code != http.StatusBadRequest { + t.Fatalf("phone: expected 400, got %d %v", code, res) + } +} + +func TestRegisterStatusDisabledByDefault(t *testing.T) { + e := setup(t) + code, body := e.do(t, "GET", "/api/auth/register-status", "", nil) + if code != http.StatusOK { + t.Fatalf("status: %d %v", code, body) + } + if body["enabled"] != false { + t.Fatalf("expected disabled, got %v", body) + } +} + +func TestRegisterRejectedWhenDisabled(t *testing.T) { + e := setup(t) + code, body := e.do(t, "POST", "/api/auth/register", "", registerBody(t, e, + "09121110001", "reg-off@test.dev", "regoff", + )) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden when disabled, got %d %v", code, body) + } +} + +func TestRegisterSuccess(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110002") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.10", + }, registerBody(t, e, "09121110002", "reg-ok@test.dev", "regok")) + if code != http.StatusCreated { + t.Fatalf("register: %d %v", code, body) + } + if body["accessToken"] == nil || body["refreshToken"] == nil || body["user"] == nil { + t.Fatalf("missing tokens/user: %v", body) + } + user := body["user"].(map[string]any) + if user["role"] != "student" { + t.Fatalf("expected student role, got %v", user["role"]) + } + if user["phone"] != "09121110002" { + t.Fatalf("unexpected phone: %v", user["phone"]) + } + if _, ok := user["passwordHash"]; ok { + t.Fatal("password hash must not be exposed") + } + if _, ok := user["securityAnswerHash"]; ok { + t.Fatal("security answer hash must not be exposed") + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=consumed", admin, nil) + if code != http.StatusOK { + t.Fatalf("list consumed: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) != 1 { + t.Fatalf("expected 1 consumed entry, got %v", body) + } + if entries[0].(map[string]any)["phone"] != "09121110002" { + t.Fatalf("unexpected consumed phone: %v", entries[0]) + } +} + +func TestRegisterWithoutWhitelistRejected(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.15", + }, registerBody(t, e, "09121110999", "reg-nowl@test.dev", "regnowl")) + if code != http.StatusForbidden { + t.Fatalf("expected forbidden without whitelist, got %d %v", code, body) + } + if body["error"] != "این شماره در فهرست مجاز نیست" { + t.Fatalf("unexpected error: %v", body["error"]) + } +} + +func TestRegisterWithoutWhitelistAllowedWhenOptional(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{ + "settings": map[string]string{ + "registration_enabled": "true", + "registration_require_whitelist": "false", + }, + }) + if code != http.StatusOK { + t.Fatalf("disable whitelist requirement: %d %v", code, body) + } + + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.115", + }, registerBody(t, e, "09121110888", "reg-optwl@test.dev", "regoptwl")) + if code != http.StatusCreated { + t.Fatalf("expected register without whitelist when optional, got %d %v", code, body) + } +} + +func TestRegisterWhitelistConsumedOnSecondUse(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110004") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.16", + }, registerBody(t, e, "09121110004", "reg-wl1@test.dev", "regwl1")) + if code != http.StatusCreated { + t.Fatalf("first: %d %v", code, body) + } + // Same phone already on user — uniqueness hits first; use a fresh attempt message path + // by checking list shows consumed and a different unused phone still works. + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("list available: %d %v", code, body) + } + if len(body["items"].([]any)) != 0 { + t.Fatalf("expected no available after consume, got %v", body) + } +} + +func TestRegisterDuplicatePhone(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110003") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.20", + }, registerBody(t, e, "09121110003", "reg-p1@test.dev", "regp1")) + if code != http.StatusCreated { + t.Fatalf("first: %d %v", code, body) + } + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.21", + }, registerBody(t, e, "09121110003", "reg-p2@test.dev", "regp2")) + if code != http.StatusConflict { + t.Fatalf("expected phone conflict, got %d %v", code, body) + } +} + +func TestRegisterSameIPRejected(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110007", "09121110008") + + headers := map[string]string{"X-Forwarded-For": "203.0.113.40"} + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", headers, + registerBody(t, e, "09121110007", "reg-ip1@test.dev", "regip1")) + if code != http.StatusCreated { + t.Fatalf("first: %d %v", code, body) + } + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", headers, + registerBody(t, e, "09121110008", "reg-ip2@test.dev", "regip2")) + if code != http.StatusConflict { + t.Fatalf("expected IP conflict, got %d %v", code, body) + } +} + +func TestRegisterUsesForwardedIP(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09121110009", "09121110010", "09121110011") + + // Proxies append; the last hop is the client identity (Traefik overwrites to a single IP). + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "198.51.100.50, 10.0.0.1", + }, registerBody(t, e, "09121110009", "reg-xff@test.dev", "regxff")) + if code != http.StatusCreated { + t.Fatalf("register via XFF: %d %v", code, body) + } + // Same last-hop client IP must be blocked even with a different leading chain. + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.9, 10.0.0.1", + }, registerBody(t, e, "09121110010", "reg-xff2@test.dev", "regxff2")) + if code != http.StatusConflict { + t.Fatalf("expected same XFF client IP rejected, got %d %v", code, body) + } + // X-Real-IP alone also counts as a distinct registration IP. + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Real-IP": "198.51.100.60", + }, registerBody(t, e, "09121110011", "reg-xri@test.dev", "regxri")) + if code != http.StatusCreated { + t.Fatalf("register via X-Real-IP: %d %v", code, body) + } +} diff --git a/backend/internal/api/route_label_test.go b/backend/internal/api/route_label_test.go new file mode 100644 index 0000000..d475eef --- /dev/null +++ b/backend/internal/api/route_label_test.go @@ -0,0 +1,25 @@ +package api + +import ( + "net/http" + "testing" +) + +func TestRouteLabelRedactsSecrets(t *testing.T) { + cases := []struct { + path string + want string + }{ + {"/api/exports/chats/abcSecretTokenXYZ", "/api/exports/chats/{token}"}, + {"/api/auth/register-invite/invitetokensecret", "/api/auth/register-invite/{token}"}, + {"/api/admin/users/42", "/api/admin/users/{id}"}, + {"/api/lessons/7/heartbeat", "/api/lessons/{id}/heartbeat"}, + } + for _, tc := range cases { + req, _ := http.NewRequest("GET", tc.path, nil) + got := routeLabel(req) + if got != tc.want { + t.Fatalf("path %s: got %q want %q", tc.path, got, tc.want) + } + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..914de64 --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,154 @@ +package api + +import ( + "net/http" + + "pargar/backend/internal/models" +) + +func newRouter(s *Server) http.Handler { + mux := http.NewServeMux() + + // ---- public ---- + mux.HandleFunc("GET /api/auth/captcha", s.authRateLimit(captchaRateLimit, "captcha", s.handleCaptcha)) + mux.HandleFunc("POST /api/auth/login", s.authRateLimit(loginBaseLimit, "login", s.handleLogin)) + mux.HandleFunc("POST /api/auth/refresh", s.authRateLimit(refreshRateLimit, "refresh", s.handleRefresh)) + mux.HandleFunc("POST /api/auth/recovery/challenge", s.authRateLimit(loginBaseLimit, "recovery", s.handleRecoveryChallenge)) + mux.HandleFunc("POST /api/auth/recovery/reset", s.authRateLimit(loginBaseLimit, "recovery", s.handleRecoveryReset)) + mux.HandleFunc("GET /api/auth/register-status", s.handleRegisterStatus) + mux.HandleFunc("POST /api/auth/register", s.authRateLimit(loginBaseLimit, "register", s.handleRegister)) + mux.HandleFunc("GET /api/auth/register-invite/{token}", s.authRateLimit(loginBaseLimit, "register-invite-status", s.handleGetRegisterInvite)) + mux.HandleFunc("POST /api/auth/register-invite/{token}", s.authRateLimit(loginBaseLimit, "register-invite", s.handleRegisterInvite)) + mux.HandleFunc("GET /api/certificates/{publicId}", s.handlePublicCertificate) + mux.HandleFunc("GET /api/community", s.handleCommunity) + mux.HandleFunc("GET /api/sponsors", s.handleGetSponsors) + mux.HandleFunc("GET /api/exports/chats/{token}", s.ipRateLimit(chatExportRateLimit, chatExportRateKey, s.handleExternalChatExport)) + + // ---- authenticated ---- + mux.HandleFunc("GET /api/auth/me", s.requireAuth(s.handleMe)) + mux.HandleFunc("POST /api/auth/logout", s.requireAuth(s.handleLogout)) + mux.HandleFunc("PUT /api/me/avatar", s.requireAuth(s.handlePutAvatar)) + mux.HandleFunc("POST /api/me/avatar-photo", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleAvatarPhotoUpload))) + mux.HandleFunc("GET /api/me/certificate", s.requireAuth(s.handleMyCertificate)) + mux.HandleFunc("POST /api/me/certificate/issue", s.requireAuth(s.handleIssueCertificate)) + mux.HandleFunc("POST /api/me/certificate/physical", s.requireAuth(s.handleRequestPhysicalCertificate)) + + mux.HandleFunc("GET /api/tree", s.requireAuth(s.handleTree)) + + mux.HandleFunc("GET /api/lessons/{id}", s.requireAuth(s.handleLesson)) + mux.HandleFunc("GET /api/lessons/{id}/resume", s.requireAuth(s.handleResume)) + mux.HandleFunc("POST /api/lessons/{id}/heartbeat", s.requireAuth(s.lockedEnforce(s.handleHeartbeat))) + mux.HandleFunc("POST /api/lessons/{id}/complete", s.requireAuth(s.handleComplete)) + mux.HandleFunc("GET /api/lessons/{id}/quiz", s.requireAuth(s.handleQuiz)) + mux.HandleFunc("POST /api/lessons/{id}/quiz/submit", s.requireAuth(s.lockedEnforce(s.handleQuizSubmit))) + mux.HandleFunc("GET /api/lessons/{id}/quiz/result", s.requireAuth(s.handleQuizResult)) + + mux.HandleFunc("GET /api/leaderboard/weekly", s.requireAuth(s.handleWeeklyLeaderboard)) + mux.HandleFunc("GET /api/challenges", s.requireAuth(s.handleListChallenges)) + + mux.HandleFunc("GET /api/events", s.requireAuth(s.handleListEvents)) + mux.HandleFunc("GET /api/events/{id}", s.requireAuth(s.handleGetEvent)) + mux.HandleFunc("GET /api/events/{id}/ics", s.requireAuth(s.handleEventICS)) + mux.HandleFunc("POST /api/events/{id}/rsvp", s.requireAuth(s.handleRsvp)) + mux.HandleFunc("DELETE /api/events/{id}/rsvp", s.requireAuth(s.handleUnrsvp)) + + mux.HandleFunc("GET /api/mentors", s.requireAuth(s.handleListMentors)) + mux.HandleFunc("GET /api/chats/conversations", s.requireAuth(s.handleConversations)) + mux.HandleFunc("GET /api/chats/{partner}/messages", s.requireAuth(s.handleChatMessages)) + mux.HandleFunc("POST /api/chats/{partner}/messages", s.requireAuth(s.handleChatSend)) + mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete)) + mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/reaction", s.requireAuth(s.handleChatReact)) + mux.HandleFunc("POST /api/chats/{partner}/messages/{id}/pin", s.requireAuth(s.handleChatPin)) + mux.HandleFunc("POST /api/chats/upload", s.requireAuth(s.perUserRateLimit(uploadRateLimit, "upload", s.handleChatUpload))) + mux.HandleFunc("POST /api/chats/{partner}/read", s.requireAuth(s.handleChatRead)) + + mux.HandleFunc("GET /api/notifications", s.requireAuth(s.handleListNotifications)) + mux.HandleFunc("GET /api/notifications/unread-count", s.requireAuth(s.handleUnreadCount)) + mux.HandleFunc("POST /api/notifications/read-all", s.requireAuth(s.handleMarkAllRead)) + mux.HandleFunc("POST /api/notifications/{id}/read", s.requireAuth(s.handleMarkRead)) + mux.HandleFunc("GET /api/notifications/preferences", s.requireAuth(s.handleGetPrefs)) + mux.HandleFunc("PUT /api/notifications/preferences", s.requireAuth(s.handlePutPrefs)) + + mux.HandleFunc("POST /api/me/request-unlock", s.requireAuth(s.handleRequestUnlock)) + mux.HandleFunc("POST /api/me/request-reset", s.requireAuth(s.handleRequestReset)) + + mux.HandleFunc("GET /api/ws", s.handleWS) + + // ---- admin / mentor ---- + admin := func(next http.HandlerFunc) http.HandlerFunc { return s.requireRole(models.RoleAdmin)(next) } + staff := func(next http.HandlerFunc) http.HandlerFunc { + return s.requireRole(models.RoleAdmin, models.RoleMentor)(next) + } + + // Signed arbitrary keys: staff only (students get lesson URLs from GET /api/lessons/{id}). + mux.HandleFunc("GET /api/media/sign", staff(s.handleSignMedia)) + + mux.HandleFunc("GET /api/admin/stats", admin(s.handleAdminStats)) + mux.HandleFunc("GET /api/admin/learning/overview", staff(s.handleLearningOverview)) + mux.HandleFunc("GET /api/admin/learning/students", staff(s.handleLearningStudents)) + mux.HandleFunc("GET /api/admin/users/{id}/learning", staff(s.handleUserLearning)) + // Account administration is admin-only; mentors may unlock locked students only. + mux.HandleFunc("GET /api/admin/users", admin(s.handleAdminListUsers)) + mux.HandleFunc("POST /api/admin/users", admin(s.handleAdminCreateUser)) + mux.HandleFunc("POST /api/admin/users/{id}/lock", admin(s.handleAdminLockUser)) + mux.HandleFunc("POST /api/admin/users/{id}/unlock", staff(s.handleAdminUnlockUser)) + mux.HandleFunc("PUT /api/admin/users/{id}", admin(s.handleAdminUpdateUser)) + mux.HandleFunc("DELETE /api/admin/users/{id}", admin(s.handleAdminDeleteUser)) + mux.HandleFunc("POST /api/admin/users/{id}/role", admin(s.handleAdminSetRole)) + mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword)) + mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders)) + mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder)) + mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings)) + mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings)) + mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport)) + mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats)) + mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore)) + mux.HandleFunc("GET /api/admin/chat-export/tokens", admin(s.handleAdminListChatExportTokens)) + mux.HandleFunc("POST /api/admin/chat-export/tokens", admin(s.handleAdminCreateChatExportToken)) + mux.HandleFunc("POST /api/admin/chat-export/tokens/{id}/revoke", admin(s.handleAdminRevokeChatExportToken)) + mux.HandleFunc("GET /api/admin/invites", admin(s.handleAdminListInvites)) + mux.HandleFunc("POST /api/admin/invites", admin(s.handleAdminCreateInvite)) + mux.HandleFunc("PUT /api/admin/invites/{id}", admin(s.handleAdminUpdateInvite)) + mux.HandleFunc("POST /api/admin/invites/{id}/pause", admin(s.handleAdminPauseInvite)) + mux.HandleFunc("POST /api/admin/invites/{id}/resume", admin(s.handleAdminResumeInvite)) + mux.HandleFunc("POST /api/admin/invites/{id}/revoke", admin(s.handleAdminRevokeInvite)) + mux.HandleFunc("GET /api/admin/phone-whitelist", admin(s.handleAdminListWhitelist)) + mux.HandleFunc("POST /api/admin/phone-whitelist/import", admin(s.handleAdminImportWhitelist)) + mux.HandleFunc("DELETE /api/admin/phone-whitelist/{id}", admin(s.handleAdminDeleteWhitelist)) + mux.HandleFunc("GET /api/admin/phone-blacklist", admin(s.handleAdminListBlacklist)) + mux.HandleFunc("GET /api/admin/phone-blacklist/attempts", admin(s.handleAdminListBlacklistAttempts)) + mux.HandleFunc("POST /api/admin/phone-blacklist/import", admin(s.handleAdminImportBlacklist)) + mux.HandleFunc("DELETE /api/admin/phone-blacklist/{id}", admin(s.handleAdminDeleteBlacklist)) + + mux.HandleFunc("GET /api/admin/chapters", staff(s.handleAdminListChapters)) + mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter)) + mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter)) + mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter)) + + mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons)) + mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson)) + mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson)) + mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson)) + mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions)) + mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion)) + mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion)) + mux.HandleFunc("DELETE /api/admin/questions/{id}", staff(s.handleAdminDeleteQuestion)) + + mux.HandleFunc("GET /api/admin/videos", staff(s.handleAdminListVideos)) + mux.HandleFunc("POST /api/admin/videos", staff(s.handleAdminUploadVideo)) + mux.HandleFunc("DELETE /api/admin/videos", staff(s.handleAdminDeleteVideo)) + + mux.HandleFunc("GET /api/admin/events", staff(s.handleAdminListEvents)) + mux.HandleFunc("POST /api/admin/events", staff(s.handleAdminCreateEvent)) + mux.HandleFunc("PUT /api/admin/events/{id}", staff(s.handleAdminUpdateEvent)) + mux.HandleFunc("DELETE /api/admin/events/{id}", staff(s.handleAdminDeleteEvent)) + + mux.HandleFunc("GET /api/admin/challenges", admin(s.handleAdminListChallenges)) + mux.HandleFunc("POST /api/admin/challenges", admin(s.handleAdminCreateChallenge)) + mux.HandleFunc("PUT /api/admin/challenges/{id}", admin(s.handleAdminUpdateChallenge)) + mux.HandleFunc("DELETE /api/admin/challenges/{id}", admin(s.handleAdminDeleteChallenge)) + + mux.HandleFunc("POST /api/admin/announce", admin(s.handleAdminAnnounce)) + + return mux +} diff --git a/backend/internal/api/scheduler.go b/backend/internal/api/scheduler.go new file mode 100644 index 0000000..bb22897 --- /dev/null +++ b/backend/internal/api/scheduler.go @@ -0,0 +1,127 @@ +package api + +import ( + "context" + "time" + + "pargar/backend/internal/config" + "pargar/backend/internal/store" +) + +// Scheduler runs periodic background jobs. +type Scheduler struct { + store *store.Store + srv *Server + cfg *config.Config + stop chan struct{} +} + +// reminder windows (relative to event start) +var reminderWindows = []struct { + kind string + dur time.Duration +}{ + {"1h", time.Hour}, + {"15m", 15 * time.Minute}, +} + +func NewScheduler(srv *Server, st *store.Store, cfg *config.Config) *Scheduler { + return &Scheduler{store: st, srv: srv, cfg: cfg, stop: make(chan struct{})} +} + +func (sh *Scheduler) Run() { + ticker := time.NewTicker(30 * time.Second) + for { + select { + case <-sh.stop: + return + case <-ticker.C: + sh.runOnce() + } + } +} + +func (sh *Scheduler) Stop() { close(sh.stop) } + +func (sh *Scheduler) runOnce() { + ctx := context.Background() + // 1. event reminders for RSVPers + now := time.Now() + for _, win := range reminderWindows { + startFrom := now.Add(-time.Minute) + endAt := now.Add(win.dur + time.Minute) + rows, err := sh.store.Pool().Query(ctx, ` + SELECT DISTINCT r.event_id, r.user_id + FROM event_rsvps r JOIN events e ON e.id = r.event_id + WHERE e.is_active AND e.starts_at BETWEEN $1 AND $2`, startFrom, endAt) + if err != nil { + continue + } + for rows.Next() { + var eventID, userID int64 + if rows.Scan(&eventID, &userID) != nil { + continue + } + ref := "event:" + itoa(eventID) + ":" + win.kind + sent, err := sh.store.ReminderSent(ctx, userID, "event_reminder", ref) + if err != nil || sent { + continue + } + e, err := sh.store.GetEvent(ctx, eventID) + if err != nil { + continue + } + _ = sh.srv.notify.Notify(ctx, userID, "event", "event_reminder", + "رویداد "+reminderLabel(win.kind)+" آغاز می‌شود", + "رویداد «"+e.Title+"» "+reminderHuman(win.kind)+" آغاز می‌شود. نوع برگزاری: "+e.EventType+"؛ برای پیوستن وارد بخش رویدادها شوید.", + "/unwrap?tab=events", map[string]any{"eventId": eventID}) + _ = sh.store.MarkReminderSent(ctx, userID, "event_reminder", ref) + } + rows.Close() + } + + // 3. streak at risk (missed a day of activity) + rows2, err := sh.store.Pool().Query(ctx, ` + SELECT id FROM users + WHERE is_active AND NOT is_locked AND streak_current > 0 + AND last_activity_date IS NOT NULL AND last_activity_date = (now() - interval '1 day')::date`) + if err == nil { + for rows2.Next() { + var uid int64 + if rows2.Scan(&uid) != nil { + continue + } + ref := time.Now().Format("2006-01-02") + sent, err := sh.store.ReminderSent(ctx, uid, "streak_at_risk", ref) + if err != nil || sent { + continue + } + u, err := sh.store.GetUserByID(ctx, uid) + if err != nil { + continue + } + _ = sh.srv.notify.Notify(ctx, uid, "gamification", "streak_at_risk", + "زنجیرهٔ فعالیت در خطر است", + "امروز یک درس را کامل کنید تا زنجیرهٔ "+itoa(int64(u.StreakCurrent))+"روزهٔ شما حفظ شود.", + "/cap", nil) + _ = sh.store.MarkReminderSent(ctx, uid, "streak_at_risk", ref) + } + rows2.Close() + } + + if _, err := sh.store.PurgeOldHeartbeats(ctx, 30*24*time.Hour); err != nil { + // non-fatal: log via notify path would be overkill; ignore + _ = err + } +} + +func reminderLabel(kind string) string { + if kind == "15m" { + return "به‌زودی" + } + return "تا یک ساعت دیگر" +} + +func reminderHuman(kind string) string { + return "حدوداً " + reminderLabel(kind) +} diff --git a/backend/internal/api/security_test.go b/backend/internal/api/security_test.go new file mode 100644 index 0000000..e332798 --- /dev/null +++ b/backend/internal/api/security_test.go @@ -0,0 +1,246 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + "pargar/backend/internal/api" + "pargar/backend/internal/config" + "pargar/backend/internal/storage" + "pargar/backend/internal/store" +) + +func TestCaptchaAnswerNotLeakedWithoutExposeFlag(t *testing.T) { + e := setup(t) + cfg := config.Load() + cfg.DevMode = true + cfg.CaptchaExposeAnswer = false + st := store.New(e.pool) + srv := api.NewServer(cfg, st, e.rdb, storage.NewLocal(t.TempDir(), "http://localhost:8080")) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest("GET", ts.URL+"/api/auth/captcha", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("captcha: %v", err) + } + defer resp.Body.Close() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("captcha: %d %v", resp.StatusCode, body) + } + if _, ok := body["answer"]; ok { + t.Fatalf("captcha answer must not be exposed when CAPTCHA_EXPOSE_ANSWER is off: %v", body) + } + if body["challengeId"] == "" || body["imageBase64"] == "" { + t.Fatalf("expected challenge fields: %v", body) + } +} + +func TestClientIPUsesLastForwardedHop(t *testing.T) { + e := setup(t) + prev := api.SetAPIGlobalLimitForTest(3) + t.Cleanup(func() { api.SetAPIGlobalLimitForTest(prev) }) + + // Spoofed leading IP must not create a separate bucket — last hop is authoritative. + spoofed := "198.51.100.1, 203.0.113.10" + hitLimit := false + for i := 0; i < 10; i++ { + req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/register-status", nil) + req.Header.Set("X-Forwarded-For", spoofed) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + hitLimit = true + break + } + } + if !hitLimit { + t.Fatal("expected rate limit on last XFF hop") + } + + // X-Real-IP is preferred and should share the same client identity. + req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/register-status", nil) + req.Header.Set("X-Real-IP", "203.0.113.10") + req.Header.Set("X-Forwarded-For", "198.51.100.99") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("x-real-ip: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("X-Real-IP should share last-hop budget, got %d", resp.StatusCode) + } + + code, body := e.doHeaders(t, "GET", "/api/auth/register-status", "", map[string]string{ + "X-Forwarded-For": "203.0.113.77", + }, nil) + if code != http.StatusOK { + t.Fatalf("other client should succeed, got %d %v", code, body) + } +} + +func TestWebSocketSubprotocolAuth(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("parse: %v", err) + } + u.Scheme = "ws" + u.Path = "/api/ws" + + dialer := websocket.Dialer{ + HandshakeTimeout: 3 * time.Second, + Subprotocols: []string{"bearer", token}, + } + conn, resp, err := dialer.Dial(u.String(), nil) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("subprotocol dial: status=%d err=%v", status, err) + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected 101, got %d", resp.StatusCode) + } + if conn.Subprotocol() != "bearer" { + t.Fatalf("expected negotiated bearer subprotocol, got %q", conn.Subprotocol()) + } + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read welcome: %v", err) + } + if !strings.Contains(string(msg), `"connected"`) { + t.Fatalf("unexpected welcome: %s", msg) + } +} + +func TestWebSocketQueryTokenRejected(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("parse: %v", err) + } + u.Scheme = "ws" + u.Path = "/api/ws" + q := u.Query() + q.Set("token", token) + u.RawQuery = q.Encode() + + dialer := websocket.Dialer{HandshakeTimeout: 3 * time.Second} + conn, resp, err := dialer.Dial(u.String(), nil) + if err == nil { + _ = conn.Close() + t.Fatal("query-string JWT must be rejected") + } + if resp == nil || resp.StatusCode != http.StatusUnauthorized { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("legacy query dial: want 401 got status=%d err=%v", status, err) + } +} + +func TestWebSocketHandshakeRateLimit(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + prev := api.SetWSHandshakeLimitForTest(2) + t.Cleanup(func() { api.SetWSHandshakeLimitForTest(prev) }) + + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("parse: %v", err) + } + u.Scheme = "ws" + u.Path = "/api/ws" + + header := http.Header{} + header.Set("X-Real-IP", "198.51.100.88") + dialer := websocket.Dialer{ + HandshakeTimeout: 3 * time.Second, + Subprotocols: []string{"bearer", token}, + } + + var lastStatus int + for i := 0; i < 5; i++ { + conn, resp, err := dialer.Dial(u.String(), header) + if err == nil { + _ = conn.Close() + lastStatus = http.StatusSwitchingProtocols + continue + } + if resp != nil { + lastStatus = resp.StatusCode + if resp.StatusCode == http.StatusTooManyRequests { + return + } + } + } + t.Fatalf("expected WS handshake rate limit, last status=%d", lastStatus) +} + +func TestChatUploadRateLimit(t *testing.T) { + e := setup(t) + token, _ := register(t, e, "Uploader", "uploader@test.dev") + + hitLimit := false + for i := 0; i < 30; i++ { + code, body := postTinyUpload(t, e, token) + if code == http.StatusTooManyRequests { + hitLimit = true + break + } + if code != http.StatusCreated { + t.Fatalf("upload %d: %d %v", i+1, code, body) + } + } + if !hitLimit { + t.Fatal("expected per-user upload rate limit") + } +} + +func postTinyUpload(t *testing.T, e *testEnv, token string) (int, map[string]any) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("file", "note.txt") + if err != nil { + t.Fatalf("form: %v", err) + } + _, _ = part.Write([]byte("hi")) + _ = w.Close() + + req, _ := http.NewRequest("POST", e.server.URL+"/api/chats/upload", &buf) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer resp.Body.Close() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + return resp.StatusCode, body +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go new file mode 100644 index 0000000..5236248 --- /dev/null +++ b/backend/internal/api/server.go @@ -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 } +} diff --git a/backend/internal/api/session_cookies.go b/backend/internal/api/session_cookies.go new file mode 100644 index 0000000..f9cba42 --- /dev/null +++ b/backend/internal/api/session_cookies.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + "strings" + "time" +) + +const ( + cookieAccess = "pargar_access" + cookieRefresh = "pargar_refresh" +) + +func (s *Server) setSessionCookies(w http.ResponseWriter, access, refresh string, refreshTTL time.Duration) { + secure := s.cfg.CookieSecure() + http.SetCookie(w, &http.Cookie{ + Name: cookieAccess, + Value: access, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(s.cfg.AccessTokenTTL.Seconds()), + }) + maxAge := int(refreshTTL.Seconds()) + if maxAge <= 0 { + maxAge = int((24 * time.Hour).Seconds()) + } + http.SetCookie(w, &http.Cookie{ + Name: cookieRefresh, + Value: refresh, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: maxAge, + }) +} + +func (s *Server) clearSessionCookies(w http.ResponseWriter) { + secure := s.cfg.CookieSecure() + expired := &http.Cookie{ + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + } + a := *expired + a.Name = cookieAccess + http.SetCookie(w, &a) + r := *expired + r.Name = cookieRefresh + http.SetCookie(w, &r) +} + +func accessTokenFromRequest(r *http.Request) string { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + } + if c, err := r.Cookie(cookieAccess); err == nil && c.Value != "" { + return c.Value + } + return "" +} + +func refreshTokenFromRequest(r *http.Request, bodyToken string) string { + if strings.TrimSpace(bodyToken) != "" { + return strings.TrimSpace(bodyToken) + } + if c, err := r.Cookie(cookieRefresh); err == nil && c.Value != "" { + return c.Value + } + return "" +} diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go new file mode 100644 index 0000000..34cf1d2 --- /dev/null +++ b/backend/internal/api/settings.go @@ -0,0 +1,178 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "pargar/backend/internal/observability" +) + +func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) { + sponsors := s.loadSponsors(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{"sponsors": sponsors}) +} + +func (s *Server) loadSponsors(ctx context.Context) []map[string]string { + raw, err := s.store.GetSetting(ctx, "sponsors") + if err != nil || strings.TrimSpace(raw) == "" { + return []map[string]string{} + } + var list []map[string]string + if err := json.Unmarshal([]byte(raw), &list); err != nil { + return []map[string]string{} + } + out := make([]map[string]string, 0, len(list)) + for _, item := range list { + name := strings.TrimSpace(item["name"]) + if name == "" { + continue + } + out = append(out, map[string]string{ + "name": name, + "url": strings.TrimSpace(item["url"]), + "blurb": strings.TrimSpace(item["blurb"]), + "logo": strings.TrimSpace(item["logo"]), + }) + } + return out +} + +func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + keys := []string{ + "donation_enabled", "donation_note", + "physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days", + "sponsors", + "registration_enabled", + "registration_require_whitelist", + } + out := map[string]string{} + for _, k := range keys { + if v, err := s.store.GetSetting(ctx, k); err == nil { + out[k] = v + } else { + out[k] = "" + } + } + if strings.TrimSpace(out["donation_note"]) == "" { + out["donation_note"] = s.cfg.DonationNote + } + if strings.TrimSpace(out["donation_enabled"]) == "" { + if s.cfg.DonationEnabled { + out["donation_enabled"] = "true" + } else { + out["donation_enabled"] = "false" + } + } + if strings.TrimSpace(out["physical_cert_enabled"]) == "" { + if s.cfg.PhysicalCertEnabled { + out["physical_cert_enabled"] = "true" + } else { + out["physical_cert_enabled"] = "false" + } + } + if strings.TrimSpace(out["physical_cert_price_irr"]) == "" { + out["physical_cert_price_irr"] = strconv.Itoa(s.cfg.PhysicalCertPriceIRR) + } + if strings.TrimSpace(out["physical_cert_window_days"]) == "" { + out["physical_cert_window_days"] = strconv.Itoa(s.cfg.PhysicalCertWindowDays) + } + if strings.TrimSpace(out["registration_enabled"]) == "" { + out["registration_enabled"] = "false" + } + if strings.TrimSpace(out["registration_require_whitelist"]) == "" { + out["registration_require_whitelist"] = "true" + } + writeJSON(w, http.StatusOK, map[string]any{"settings": out}) +} + +func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) { + var req struct { + Settings map[string]string `json:"settings"` + } + if err := bodyJSON(r, &req); err != nil || req.Settings == nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + allowed := map[string]bool{ + "donation_enabled": true, + "donation_note": true, + "physical_cert_enabled": true, + "physical_cert_price_irr": true, + "physical_cert_window_days": true, + "sponsors": true, + "registration_enabled": true, + "registration_require_whitelist": true, + } + for k, v := range req.Settings { + if !allowed[k] { + continue + } + v = strings.TrimSpace(v) + switch k { + case "donation_enabled", "physical_cert_enabled", "registration_enabled", "registration_require_whitelist": + if v == "1" || strings.EqualFold(v, "true") || v == "yes" { + v = "true" + } else { + v = "false" + } + case "physical_cert_price_irr", "physical_cert_window_days": + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + writeErr(w, http.StatusBadRequest, "مقدار عددی نامعتبر است: "+k) + return + } + if k == "physical_cert_window_days" && (n < 1 || n > 3650) { + writeErr(w, http.StatusBadRequest, "مهلت گواهینامه باید بین ۱ تا ۳۶۵۰ روز باشد") + return + } + case "sponsors": + if v == "" { + v = "[]" + } + var tmp []map[string]string + if err := json.Unmarshal([]byte(v), &tmp); err != nil { + writeErr(w, http.StatusBadRequest, "فرمت اسپانسرها نامعتبر است") + return + } + if len(tmp) > 20 { + writeErr(w, http.StatusBadRequest, "حداکثر ۲۰ اسپانسر مجاز است") + return + } + for _, item := range tmp { + name := strings.TrimSpace(item["name"]) + if name == "" { + writeErr(w, http.StatusBadRequest, "نام اسپانسر الزامی است") + return + } + if utf8.RuneCountInString(name) > 100 { + writeErr(w, http.StatusBadRequest, "نام اسپانسر خیلی طولانی است") + return + } + if utf8.RuneCountInString(strings.TrimSpace(item["blurb"])) > 300 { + writeErr(w, http.StatusBadRequest, "توضیح اسپانسر خیلی طولانی است") + return + } + if utf8.RuneCountInString(strings.TrimSpace(item["url"])) > 500 { + writeErr(w, http.StatusBadRequest, "لینک اسپانسر خیلی طولانی است") + return + } + } + case "donation_note": + if utf8.RuneCountInString(v) > 2000 { + writeErr(w, http.StatusBadRequest, "یادداشت دونیت خیلی طولانی است") + return + } + } + if err := s.store.SetSetting(r.Context(), k, v); err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیره تنظیمات ممکن نشد") + return + } + } + observability.Activity("settings.updated", "actor_id", currentUser(r).ID) + s.handleAdminGetSettings(w, r) +} diff --git a/backend/internal/api/tree.go b/backend/internal/api/tree.go new file mode 100644 index 0000000..b77305d --- /dev/null +++ b/backend/internal/api/tree.go @@ -0,0 +1,283 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "pargar/backend/internal/models" + "pargar/backend/internal/observability" + "pargar/backend/internal/service" + "pargar/backend/internal/store" +) + +// handleTree returns the full curriculum with per-user progress, quiz state and lock resolution. +func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + chapters, err := s.store.ListChapters(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فصل‌ها ممکن نشد") + return + } + lessons, err := s.store.ListLessons(r.Context(), u.Role.IsStaff()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری درس‌ها ممکن نشد") + return + } + progress, err := s.store.ProgressMap(r.Context(), u.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری پیشرفت ممکن نشد") + return + } + attempts := map[int64]*models.QuizAttempt{} + for _, l := range lessons { + at, err := s.store.LastAttempt(r.Context(), u.ID, l.ID) + if err == nil && at != nil { + attempts[l.ID] = at + } + } + + type lessonView struct { + models.Lesson + Progress *models.LessonProgress `json:"progress"` + QuizStatus string `json:"quizStatus"` + Locked bool `json:"locked"` + } + type chapterView struct { + models.Chapter + Lessons []lessonView `json:"lessons"` + } + + complete := map[int64]bool{} + for _, l := range lessons { + if p, ok := progress[l.ID]; ok && p.PassedQuiz { + complete[l.ID] = true + } + } + + out := make([]chapterView, 0, len(chapters)) + for _, ch := range chapters { + cv := chapterView{Chapter: ch, Lessons: []lessonView{}} + for _, l := range lessons { + if l.ChapterID != ch.ID { + continue + } + lv := lessonView{Lesson: l} + if !u.Role.IsStaff() { + lv.VideoKey = "" // never expose storage keys to students + } + if p, ok := progress[l.ID]; ok { + lv.Progress = p + } + if at, ok := attempts[l.ID]; ok { + lv.QuizStatus = at.Status + } + // root lesson (no prerequisite) is always unlocked; otherwise require prerequisite passed quiz + if l.RequiresLessonID != nil && !complete[*l.RequiresLessonID] { + lv.Locked = true + } + cv.Lessons = append(cv.Lessons, lv) + } + out = append(out, cv) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "chapters": out, + "me": map[string]any{ + "xp": u.XP, + "streak": u.StreakCurrent, + "hearts": u.Hearts, + "isLocked": u.IsLocked, + "role": u.Role, + }, + }) +} + +func (s *Server) handleLesson(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + progress, _ := s.store.GetProgress(r.Context(), u.ID, id) + attempt, _ := s.store.LastAttempt(r.Context(), u.ID, id) + + videoKey := strings.TrimSpace(lesson.VideoKey) + usingSample := false + if videoKey == "" { + // Always give the player something to show when a lesson has no uploaded video yet. + videoKey = "sample.mp4" + usingSample = true + } + videoURL := s.signMediaURL(videoKey) + writeJSON(w, http.StatusOK, map[string]any{ + "lesson": lesson, + "videoUrl": videoURL, + "videoIsSample": usingSample, + "progress": progress, + "quizStatus": attemptStatus(attempt), + }) +} + +func attemptStatus(at *models.QuizAttempt) string { + if at == nil { + return "" + } + return at.Status +} + +func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + pos, err := s.watch.ResumePosition(r.Context(), u.ID, id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری موقعیت ادامهٔ درس ممکن نشد") + return + } + lastSeq, _ := s.store.MaxHeartbeatSeq(r.Context(), u.ID, id) + writeJSON(w, http.StatusOK, map[string]any{"position": pos, "lastSeq": lastSeq}) +} + +type heartbeatRequest struct { + Position float64 `json:"position"` + Delta float64 `json:"delta"` + Seq int `json:"seq"` +} + +func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + previousProgress, _ := s.store.GetProgress(r.Context(), u.ID, id) + wasQuizUnlocked := previousProgress != nil && previousProgress.QuizUnlocked + var req heartbeatRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "ضربان پخش نامعتبر است") + return + } + ctx, span := observability.StartSpan(r.Context(), "watch.heartbeat") + defer span.End() + pg, err := s.watch.Heartbeat(ctx, u.ID, lesson, req.Position, req.Delta, req.Seq) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ثبت پیشرفت پخش ممکن نشد") + return + } + observability.Heartbeats.Inc() + unlockedNow := !wasQuizUnlocked && pg.QuizUnlocked + if unlockedNow { + observability.Activity("lesson.quiz_unlocked", + "user_id", u.ID, + "lesson_id", id, + "watched_pct", round2(pg.WatchedPct), + ) + } + writeJSON(w, http.StatusOK, map[string]any{ + "watchedSeconds": round2(pg.WatchedSeconds), + "watchedPct": round2(pg.WatchedPct), + "position": req.Position, + "quizUnlocked": pg.QuizUnlocked, + "quizUnlockedNow": unlockedNow, + }) +} + +func (s *Server) handleComplete(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + id := routeID(r, "id") + lesson, err := s.store.GetLesson(r.Context(), id) + if err != nil { + writeErr(w, http.StatusNotFound, "درس پیدا نشد") + return + } + if !s.allowLessonAccess(w, r, lesson) { + return + } + pg, err := s.store.GetProgress(r.Context(), u.ID, id) + if err != nil { + writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error()) + return + } + if pg.WatchedPct < float64(lesson.CompletionThresholdPct) || !pg.QuizUnlocked { + writeErr(w, http.StatusForbidden, "حداقل زمان تماشا تکمیل نشده است") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "quizUnlocked": pg.QuizUnlocked, + "watchedPct": round2(pg.WatchedPct), + "message": "آزمون باز شد", + }) +} + +func round2(v float64) float64 { + return float64(int64(v*100+0.5)) / 100 +} + +func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesson *models.Lesson) bool { + u := currentUser(r) + if u == nil { + writeErr(w, http.StatusUnauthorized, "نشست معتبر نیست") + return false + } + if u.IsLocked && !u.Role.IsStaff() { + writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error()) + return false + } + if !lesson.IsActive && !u.Role.IsStaff() { + writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد") + return false + } + err := s.lessonAccessError(r.Context(), u.ID, lesson) + if err == nil { + return true + } + if errors.Is(err, service.ErrLessonLocked) { + writeErrCode(w, http.StatusForbidden, CodeForbidden, service.ErrLessonLocked.Error()) + } else { + writeInternalErr(w, "lesson.access", err, "بررسی دسترسی به درس ممکن نشد") + } + return false +} + +func (s *Server) lessonAccessError(ctx context.Context, userID int64, lesson *models.Lesson) error { + if lesson.RequiresLessonID == nil { + return nil + } + progress, err := s.store.GetProgress(ctx, userID, *lesson.RequiresLessonID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return service.ErrLessonLocked + } + return err + } + if !progress.PassedQuiz { + return service.ErrLessonLocked + } + return nil +} + +func routeID(r *http.Request, key string) int64 { + id, _ := strconv.ParseInt(r.PathValue(key), 10, 64) + return id +} diff --git a/backend/internal/api/upload_sniff.go b/backend/internal/api/upload_sniff.go new file mode 100644 index 0000000..9ff28b9 --- /dev/null +++ b/backend/internal/api/upload_sniff.go @@ -0,0 +1,219 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "path/filepath" + "strings" +) + +type sniffedUpload struct { + Ext string // including leading dot + Mime string + Kind string // image|video|audio|pdf|office|text +} + +func readUploadHead(file multipart.File, n int) ([]byte, error) { + head := make([]byte, n) + got, err := io.ReadFull(file, head) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return nil, fmt.Errorf("خواندن فایل ممکن نشد") + } + if got == 0 { + return nil, fmt.Errorf("فایل خالی است") + } + head = head[:got] + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("خواندن فایل ممکن نشد") + } + } else { + return nil, fmt.Errorf("خواندن فایل ممکن نشد") + } + return head, nil +} + +func sniffImageKind(head []byte) (ext, mime string, ok bool) { + if len(head) >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF { + return ".jpg", "image/jpeg", true + } + if len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) { + return ".png", "image/png", true + } + if len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))) { + return ".gif", "image/gif", true + } + if len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")) { + return ".webp", "image/webp", true + } + return "", "", false +} + +func sniffPDF(head []byte) bool { + return len(head) >= 5 && bytes.Equal(head[:5], []byte("%PDF-")) +} + +func sniffZIP(head []byte) bool { + return len(head) >= 4 && head[0] == 0x50 && head[1] == 0x4B && (head[2] == 0x03 || head[2] == 0x05 || head[2] == 0x07) && (head[3] == 0x04 || head[3] == 0x06 || head[3] == 0x08) +} + +func sniffOLE(head []byte) bool { + return len(head) >= 8 && bytes.Equal(head[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) +} + +func sniffAudioKind(head []byte, ext string) (mime string, ok bool) { + switch ext { + case ".mp3": + if len(head) >= 3 && (bytes.Equal(head[:3], []byte("ID3")) || (head[0] == 0xFF && (head[1]&0xE0) == 0xE0)) { + return "audio/mpeg", true + } + case ".wav": + if len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WAVE")) { + return "audio/wav", true + } + case ".ogg", ".opus": + if len(head) >= 4 && bytes.Equal(head[:4], []byte("OggS")) { + return "audio/ogg", true + } + case ".m4a", ".aac": + if len(head) >= 8 && bytes.Equal(head[4:8], []byte("ftyp")) { + return "audio/mp4", true + } + } + return "", false +} + +// acceptAvatarImage requires JPEG/PNG/WebP by magic bytes (ignores client Content-Type). +func acceptAvatarImage(header *multipart.FileHeader, file multipart.File) (sniffedUpload, error) { + if header == nil || file == nil { + return sniffedUpload{}, fmt.Errorf("فایل ارسال نشده است") + } + head, err := readUploadHead(file, 16) + if err != nil { + return sniffedUpload{}, err + } + ext, mime, ok := sniffImageKind(head) + if !ok || (ext != ".jpg" && ext != ".png" && ext != ".webp") { + return sniffedUpload{}, fmt.Errorf("فقط JPEG، PNG یا WebP مجاز است") + } + nameExt := strings.ToLower(filepath.Ext(header.Filename)) + if nameExt != "" && nameExt != ext && !(nameExt == ".jpeg" && ext == ".jpg") { + return sniffedUpload{}, fmt.Errorf("پسوند فایل با محتوای آن هم‌خوان نیست") + } + return sniffedUpload{Ext: ext, Mime: mime, Kind: "image"}, nil +} + +// acceptChatUpload validates extension allow-list plus content sniffing. +func acceptChatUpload(header *multipart.FileHeader, file multipart.File) (sniffedUpload, error) { + if header == nil || file == nil { + return sniffedUpload{}, fmt.Errorf("فایلی ارسال نشده است") + } + name := filepath.Base(strings.TrimSpace(header.Filename)) + ext := strings.ToLower(filepath.Ext(name)) + if !allowedExtension(ext) { + return sniffedUpload{}, fmt.Errorf("این نوع فایل مجاز نیست") + } + head, err := readUploadHead(file, 16) + if err != nil { + return sniffedUpload{}, err + } + + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + got, mime, ok := sniffImageKind(head) + if !ok { + return sniffedUpload{}, fmt.Errorf("محتوای فایل تصویر معتبر نیست") + } + if ext == ".jpeg" { + ext = ".jpg" + } + if got == ".jpg" && ext != ".jpg" || got != ".jpg" && got != ext { + return sniffedUpload{}, fmt.Errorf("پسوند فایل با محتوای آن هم‌خوان نیست") + } + return sniffedUpload{Ext: got, Mime: mime, Kind: "image"}, nil + case ".webm": + // MediaRecorder voice notes use audio/webm but share the same EBML magic as video/webm. + declared := strings.ToLower(header.Header.Get("Content-Type")) + base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))) + asAudio := strings.HasPrefix(declared, "audio/") || strings.HasPrefix(base, "voice-") + kind, ok := sniffVideoKind(head) + if !ok || kind != "webm" { + return sniffedUpload{}, fmt.Errorf("محتوای فایل WebM معتبر نیست") + } + if asAudio { + return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil + } + return sniffedUpload{Ext: ".webm", Mime: "video/webm", Kind: "video"}, nil + case ".mp4", ".m4v": + kind, ok := sniffVideoKind(head) + if !ok || kind != "mp4" { + return sniffedUpload{}, fmt.Errorf("محتوای فایل ویدیویی معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: "video/mp4", Kind: "video"}, nil + case ".mov": + // QuickTime often looks like ISO BMFF + if kind, ok := sniffVideoKind(head); ok && kind == "mp4" { + return sniffedUpload{Ext: ext, Mime: "video/quicktime", Kind: "video"}, nil + } + return sniffedUpload{}, fmt.Errorf("محتوای فایل ویدیویی معتبر نیست") + case ".mp3", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".weba": + if ext == ".weba" { + // WebM/Matroska container used for MediaRecorder voice notes. + kind, ok := sniffVideoKind(head) + if ok && kind == "webm" { + return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil + } + // Short/fragmented first chunk may lack a full EBML header; trust voice-* + audio/*. + declared := strings.ToLower(header.Header.Get("Content-Type")) + base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))) + if strings.HasPrefix(declared, "audio/") || strings.HasPrefix(base, "voice-") { + return sniffedUpload{Ext: ".webm", Mime: "audio/webm", Kind: "audio"}, nil + } + return sniffedUpload{}, fmt.Errorf("محتوای فایل صوتی معتبر نیست") + } + mime, ok := sniffAudioKind(head, ext) + if !ok { + // Safari/Chrome sometimes emit ftyp later than 16 bytes for short m4a clips; + // accept voice notes when Content-Type is audio/* and name is voice-*. + declared := strings.ToLower(header.Header.Get("Content-Type")) + base := strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))) + if strings.HasPrefix(base, "voice-") && strings.HasPrefix(declared, "audio/") { + fallback := map[string]string{ + ".m4a": "audio/mp4", ".aac": "audio/aac", + ".ogg": "audio/ogg", ".opus": "audio/ogg", + ".mp3": "audio/mpeg", ".wav": "audio/wav", + } + if m, ok := fallback[ext]; ok { + return sniffedUpload{Ext: ext, Mime: m, Kind: "audio"}, nil + } + } + return sniffedUpload{}, fmt.Errorf("محتوای فایل صوتی معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: mime, Kind: "audio"}, nil + case ".pdf": + if !sniffPDF(head) { + return sniffedUpload{}, fmt.Errorf("محتوای فایل PDF معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: "application/pdf", Kind: "pdf"}, nil + case ".doc", ".xls", ".ppt": + if !sniffOLE(head) { + return sniffedUpload{}, fmt.Errorf("محتوای فایل Office معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: "application/msword", Kind: "office"}, nil + case ".docx", ".xlsx", ".pptx": + if !sniffZIP(head) { + return sniffedUpload{}, fmt.Errorf("محتوای فایل Office معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: "application/zip", Kind: "office"}, nil + case ".txt", ".md", ".csv", ".json": + // Reject binary-looking heads (NUL in first bytes). + if bytes.IndexByte(head, 0) >= 0 { + return sniffedUpload{}, fmt.Errorf("محتوای فایل متنی معتبر نیست") + } + return sniffedUpload{Ext: ext, Mime: "text/plain", Kind: "text"}, nil + default: + return sniffedUpload{}, fmt.Errorf("این نوع فایل مجاز نیست") + } +} diff --git a/backend/internal/api/upload_sniff_test.go b/backend/internal/api/upload_sniff_test.go new file mode 100644 index 0000000..a142ce0 --- /dev/null +++ b/backend/internal/api/upload_sniff_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "bytes" + "mime/multipart" + "net/textproto" + "testing" +) + +type memFile struct { + *bytes.Reader +} + +func (m memFile) Close() error { return nil } + +func TestSniffImageKind(t *testing.T) { + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0} + ext, mime, ok := sniffImageKind(png) + if !ok || ext != ".png" || mime != "image/png" { + t.Fatalf("png: %v %s %s", ok, ext, mime) + } + jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0} + ext, mime, ok = sniffImageKind(jpeg) + if !ok || ext != ".jpg" { + t.Fatalf("jpeg: %v %s", ok, ext) + } + if _, _, ok := sniffImageKind([]byte("not an image")); ok { + t.Fatal("expected reject") + } +} + +func TestAcceptAvatarRejectsMismatch(t *testing.T) { + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0} + h := &multipart.FileHeader{ + Filename: "photo.jpg", + Header: textproto.MIMEHeader{"Content-Type": []string{"image/jpeg"}}, + Size: int64(len(png)), + } + _, err := acceptAvatarImage(h, memFile{bytes.NewReader(png)}) + if err == nil { + t.Fatal("expected extension/content mismatch error") + } +} + +func TestAcceptAvatarPNG(t *testing.T) { + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0} + h := &multipart.FileHeader{ + Filename: "photo.png", + Size: int64(len(png)), + } + got, err := acceptAvatarImage(h, memFile{bytes.NewReader(png)}) + if err != nil { + t.Fatal(err) + } + if got.Ext != ".png" || got.Kind != "image" { + t.Fatalf("%+v", got) + } +} + +func TestAcceptChatRejectsFakePDF(t *testing.T) { + body := []byte("not a pdf file!!!!") + h := &multipart.FileHeader{Filename: "x.pdf", Size: int64(len(body))} + _, err := acceptChatUpload(h, memFile{bytes.NewReader(body)}) + if err == nil { + t.Fatal("expected fake pdf reject") + } +} + +func TestAcceptChatPDF(t *testing.T) { + body := []byte("%PDF-1.4\n%âãÏÓ\n") + h := &multipart.FileHeader{Filename: "doc.pdf", Size: int64(len(body))} + got, err := acceptChatUpload(h, memFile{bytes.NewReader(body)}) + if err != nil { + t.Fatal(err) + } + if got.Kind != "pdf" { + t.Fatalf("%+v", got) + } +} + +func TestAcceptChatVoiceWebA(t *testing.T) { + // EBML header used by MediaRecorder audio/webm;codecs=opus + body := []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x42, 0x86, 0x81, 0x01} + h := &multipart.FileHeader{ + Filename: "voice-123.weba", + Header: textproto.MIMEHeader{"Content-Type": []string{"audio/webm"}}, + Size: int64(len(body)), + } + got, err := acceptChatUpload(h, memFile{bytes.NewReader(body)}) + if err != nil { + t.Fatal(err) + } + if got.Kind != "audio" || got.Mime != "audio/webm" { + t.Fatalf("%+v", got) + } +} + +func TestAcceptChatVoiceWebMAsAudio(t *testing.T) { + body := []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x42, 0x86, 0x81, 0x01} + h := &multipart.FileHeader{ + Filename: "voice-456.webm", + Header: textproto.MIMEHeader{"Content-Type": []string{"audio/webm;codecs=opus"}}, + Size: int64(len(body)), + } + got, err := acceptChatUpload(h, memFile{bytes.NewReader(body)}) + if err != nil { + t.Fatal(err) + } + if got.Kind != "audio" { + t.Fatalf("expected audio, got %+v", got) + } +} + +func TestAcceptChatRejectsOggLabeledWebM(t *testing.T) { + // WebM bytes with a wrong .ogg extension must fail (the old client bug path). + body := []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x42, 0x86, 0x81, 0x01} + h := &multipart.FileHeader{ + Filename: "clip.ogg", + Header: textproto.MIMEHeader{"Content-Type": []string{"audio/ogg"}}, + Size: int64(len(body)), + } + _, err := acceptChatUpload(h, memFile{bytes.NewReader(body)}) + if err == nil { + t.Fatal("expected reject for webm bytes labeled as ogg") + } +} diff --git a/backend/internal/api/util.go b/backend/internal/api/util.go new file mode 100644 index 0000000..b2498c4 --- /dev/null +++ b/backend/internal/api/util.go @@ -0,0 +1,7 @@ +package api + +import "strconv" + +func strconvFormatInt(v int64) string { + return strconv.FormatInt(v, 10) +} diff --git a/backend/internal/api/validate.go b/backend/internal/api/validate.go new file mode 100644 index 0000000..e06e35f --- /dev/null +++ b/backend/internal/api/validate.go @@ -0,0 +1,228 @@ +package api + +import ( + "net/mail" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + minUsernameLen = 3 + maxUsernameLen = 32 + maxNameLen = 100 + minPasswordLen = 8 + maxPasswordLen = 72 // bcrypt limit + minSecQLen = 3 + maxSecQLen = 200 + minSecALen = 2 + maxSecALen = 100 + maxInviteLabel = 80 + maxEmailLen = 200 +) + +// Persian validation messages — keep in sync with frontend/src/lib/validation.ts +const ( + msgNameRequired = "نام الزامی است" + msgNameTooLong = "نام خیلی طولانی است" + msgEmailInvalid = "ایمیل نامعتبر است" + msgUsernameInvalid = "شناسه باید با حرف انگلیسی شروع شود و ۳ تا ۳۲ نویسهٔ لاتین، عدد، نقطه، خط یا زیرخط باشد" + msgPasswordShort = "گذرواژه باید دست‌کم ۸ نویسه داشته باشد" + msgPasswordLong = "گذرواژه بیش از حد طولانی است" + msgPasswordMismatch = "گذرواژه و تکرار آن یکسان نیستند" + msgPhoneRequired = "شماره تلفن الزامی است" + msgPhoneInvalid = "شماره تلفن نامعتبر است (مثال: ۰۹۱۲۱۲۳۴۵۶۷)" + msgSecQARequired = "سوال و جواب امنیتی الزامی است" + msgSecQShort = "سوال امنیتی خیلی کوتاه است" + msgSecQLong = "سوال امنیتی خیلی طولانی است" + msgSecAShort = "جواب امنیتی خیلی کوتاه است" + msgSecALong = "جواب امنیتی خیلی طولانی است" + msgInviteLabelLong = "برچسب لینک عضویت خیلی طولانی است" +) + +func validEmail(email string) bool { + n := utf8.RuneCountInString(email) + if n < 5 || n > maxEmailLen { + return false + } + if strings.ContainsAny(email, " \t\r\n") { + return false + } + addr, err := mail.ParseAddress(email) + if err != nil { + return false + } + // Reject display-name forms like "Name ". + if addr.Address != email { + return false + } + at := strings.LastIndex(email, "@") + if at < 1 || at == len(email)-1 { + return false + } + domain := email[at+1:] + return strings.Contains(domain, ".") +} + +// validUsername: ASCII letter first, then letters/digits/._- ; length 3–32. +// Expects already lowercased + trimmed input. +func validUsername(username string) bool { + n := len(username) + if n < minUsernameLen || n > maxUsernameLen { + return false + } + first := username[0] + if first < 'a' || first > 'z' { + return false + } + for i := 1; i < n; i++ { + c := username[i] + switch { + case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_' || c == '.' || c == '-': + continue + default: + return false + } + } + return true +} + +// normalizePhone keeps digits (maps Persian/Arabic digits) and canonicalizes +// Iranian mobiles to 09XXXXXXXXX (from +98… / 98… / 9…). +func normalizePhone(raw string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(raw) { + switch { + case r >= '۰' && r <= '۹': + b.WriteByte(byte('0' + (r - '۰'))) + case r >= '٠' && r <= '٩': + b.WriteByte(byte('0' + (r - '٠'))) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '+' && b.Len() == 0: + // skip; country code handled below from digits + } + } + s := b.String() + switch { + case len(s) == 12 && strings.HasPrefix(s, "989"): + return "0" + s[2:] + case len(s) == 10 && s[0] == '9': + return "0" + s + default: + return s + } +} + +// validPhone accepts empty (optional admin fields) or Iranian mobile 09XXXXXXXXX. +func validPhone(phone string) bool { + if phone == "" { + return true + } + if len(phone) != 11 || !strings.HasPrefix(phone, "09") { + return false + } + for _, r := range phone { + if r < '0' || r > '9' { + return false + } + } + // Second digit after 0 must be 9 (already in prefix); third is operator 0–9. + return true +} + +func validatePersonName(name string) string { + if name == "" { + return msgNameRequired + } + if utf8.RuneCountInString(name) > maxNameLen { + return msgNameTooLong + } + // Reject names that are only whitespace/control (trim already applied). + for _, r := range name { + if !unicode.IsSpace(r) && !unicode.IsControl(r) { + return "" + } + } + return msgNameRequired +} + +func validateRegisterPassword(password, confirm string) string { + if len(password) < minPasswordLen { + return msgPasswordShort + } + if len(password) > maxPasswordLen { + return msgPasswordLong + } + if password != confirm { + return msgPasswordMismatch + } + return "" +} + +func validatePasswordOnly(password string) string { + if len(password) < minPasswordLen { + return msgPasswordShort + } + if len(password) > maxPasswordLen { + return msgPasswordLong + } + return "" +} + +func validateSecurityQA(question, answer string) string { + if question == "" || answer == "" { + return msgSecQARequired + } + qn := utf8.RuneCountInString(question) + an := utf8.RuneCountInString(answer) + if qn < minSecQLen { + return msgSecQShort + } + if qn > maxSecQLen { + return msgSecQLong + } + if an < minSecALen { + return msgSecAShort + } + if an > maxSecALen { + return msgSecALong + } + return "" +} + +func validateInviteLabel(label string) string { + if utf8.RuneCountInString(label) > maxInviteLabel { + return msgInviteLabelLong + } + return "" +} + +// validateStudentRegistrationFields validates public/invite registration payloads. +// Phone is always required. Returns the first Persian error message, or "". +func validateStudentRegistrationFields( + name, email, username, phone, password, passwordConfirm, secQ, secA string, +) string { + if msg := validatePersonName(name); msg != "" { + return msg + } + if !validEmail(email) { + return msgEmailInvalid + } + if !validUsername(username) { + return msgUsernameInvalid + } + if msg := validateRegisterPassword(password, passwordConfirm); msg != "" { + return msg + } + if phone == "" { + return msgPhoneRequired + } + if !validPhone(phone) { + return msgPhoneInvalid + } + if msg := validateSecurityQA(secQ, secA); msg != "" { + return msg + } + return "" +} diff --git a/backend/internal/api/validate_test.go b/backend/internal/api/validate_test.go new file mode 100644 index 0000000..3ca001f --- /dev/null +++ b/backend/internal/api/validate_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "strings" + "testing" +) + +func TestValidUsername(t *testing.T) { + ok := []string{"abc", "user_1", "ahp.dev", "a-b-c", "z" + strings.Repeat("x", 31)} + for _, u := range ok { + if !validUsername(u) { + t.Fatalf("expected valid: %q", u) + } + } + bad := []string{ + "", "ab", "1abc", "_user", ".dot", "-dash", + "کاربر", "user name", "user@x", "emoji😀", + strings.Repeat("a", 33), "User", + } + for _, u := range bad { + if validUsername(u) { + t.Fatalf("expected invalid: %q", u) + } + } + if !validUsername(strings.ToLower("UserName")) { + t.Fatal("lowercased username should be valid") + } +} + +func TestNormalizeAndValidPhone(t *testing.T) { + cases := []struct { + in, want string + ok bool + }{ + {"09121234567", "09121234567", true}, + {"۰۹۱۲۱۲۳۴۵۶۷", "09121234567", true}, + {"+989121234567", "09121234567", true}, + {"989121234567", "09121234567", true}, + {"9121234567", "09121234567", true}, + {"", "", true}, + {"12345", "12345", false}, + {"0912123456", "0912123456", false}, + {"091212345678", "091212345678", false}, + {"08121234567", "08121234567", false}, + } + for _, c := range cases { + got := normalizePhone(c.in) + if got != c.want { + t.Fatalf("normalize(%q)=%q want %q", c.in, got, c.want) + } + if validPhone(got) != c.ok { + t.Fatalf("validPhone(%q)=%v want %v", got, validPhone(got), c.ok) + } + } +} + +func TestValidEmail(t *testing.T) { + if !validEmail("a@b.co") { + t.Fatal("expected valid email") + } + if validEmail("not-an-email") || validEmail("a@b") || validEmail("a @b.co") { + t.Fatal("expected invalid emails") + } +} + +func TestValidateStudentRegistrationFields(t *testing.T) { + msg := validateStudentRegistrationFields( + "هنرجو", "ok@test.dev", "gooduser", "09121110000", + "password123", "password123", "رنگ مورد علاقه؟", "آبی", + ) + if msg != "" { + t.Fatalf("expected ok, got %q", msg) + } + msg = validateStudentRegistrationFields( + "هنرجو", "ok@test.dev", "کاربر", "09121110000", + "password123", "password123", "رنگ؟", "آبی", + ) + if msg != msgUsernameInvalid { + t.Fatalf("persian username: got %q", msg) + } + msg = validateStudentRegistrationFields( + "هنرجو", "ok@test.dev", "gooduser", "09121110000", + "password123", "different", "رنگ؟", "آبی", + ) + if msg != msgPasswordMismatch { + t.Fatalf("mismatch: got %q", msg) + } +} diff --git a/backend/internal/api/video_accept.go b/backend/internal/api/video_accept.go new file mode 100644 index 0000000..a10368e --- /dev/null +++ b/backend/internal/api/video_accept.go @@ -0,0 +1,109 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "path/filepath" + "strings" +) + +const maxAdminVideoBytes = 200 << 20 // 200 MB + +type acceptedVideo struct { + Ext string // ".mp4" or ".webm" + Mime string +} + +// acceptAdminVideo validates extension, declared content-type, and magic bytes. +// MP4 is preferred; only MP4 and WebM are allowed. +func acceptAdminVideo(header *multipart.FileHeader, file multipart.File) (acceptedVideo, error) { + if header == nil || file == nil { + return acceptedVideo{}, fmt.Errorf("فایل ارسال نشده است") + } + if header.Size > maxAdminVideoBytes { + return acceptedVideo{}, fmt.Errorf("حجم ویدیو باید کمتر از ۲۰۰ مگابایت باشد") + } + + name := strings.ToLower(filepath.Base(strings.TrimSpace(header.Filename))) + ext := filepath.Ext(name) + declared := strings.ToLower(strings.TrimSpace(header.Header.Get("Content-Type"))) + // Strip codec params: video/mp4; codecs="..." + if i := strings.IndexByte(declared, ';'); i >= 0 { + declared = strings.TrimSpace(declared[:i]) + } + + switch ext { + case ".mp4", ".webm": + // ok + case "": + switch declared { + case "video/mp4": + ext = ".mp4" + case "video/webm": + ext = ".webm" + default: + return acceptedVideo{}, fmt.Errorf("فقط فایل‌های MP4 یا WebM مجاز است (ترجیحاً MP4)") + } + default: + return acceptedVideo{}, fmt.Errorf("فقط فایل‌های MP4 یا WebM مجاز است (ترجیحاً MP4)") + } + + if declared != "" && declared != "application/octet-stream" && declared != "video/mp4" && declared != "video/webm" { + return acceptedVideo{}, fmt.Errorf("نوع فایل پشتیبانی نمی‌شود؛ MP4 یا WebM بفرستید") + } + if declared == "video/mp4" && ext == ".webm" { + return acceptedVideo{}, fmt.Errorf("پسوند و نوع فایل هم‌خوان نیست") + } + if declared == "video/webm" && ext == ".mp4" { + return acceptedVideo{}, fmt.Errorf("پسوند و نوع فایل هم‌خوان نیست") + } + + head := make([]byte, 16) + n, err := io.ReadFull(file, head) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return acceptedVideo{}, fmt.Errorf("خواندن فایل ممکن نشد") + } + if n < 4 { + return acceptedVideo{}, fmt.Errorf("فایل ویدیویی نامعتبر است") + } + head = head[:n] + + kind, ok := sniffVideoKind(head) + if !ok { + return acceptedVideo{}, fmt.Errorf("محتوای فایل MP4 یا WebM نیست") + } + if ext == ".mp4" && kind != "mp4" { + return acceptedVideo{}, fmt.Errorf("محتوای فایل با پسوند MP4 هم‌خوان نیست") + } + if ext == ".webm" && kind != "webm" { + return acceptedVideo{}, fmt.Errorf("محتوای فایل با پسوند WebM هم‌خوان نیست") + } + + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return acceptedVideo{}, fmt.Errorf("خواندن فایل ممکن نشد") + } + } else { + return acceptedVideo{}, fmt.Errorf("خواندن فایل ممکن نشد") + } + + mime := "video/mp4" + if kind == "webm" { + mime = "video/webm" + } + return acceptedVideo{Ext: ext, Mime: mime}, nil +} + +func sniffVideoKind(head []byte) (string, bool) { + // WebM / Matroska EBML header + if len(head) >= 4 && head[0] == 0x1A && head[1] == 0x45 && head[2] == 0xDF && head[3] == 0xA3 { + return "webm", true + } + // ISO BMFF / MP4: ....ftyp + if len(head) >= 8 && bytes.Equal(head[4:8], []byte("ftyp")) { + return "mp4", true + } + return "", false +} diff --git a/backend/internal/api/video_accept_test.go b/backend/internal/api/video_accept_test.go new file mode 100644 index 0000000..c75ebbf --- /dev/null +++ b/backend/internal/api/video_accept_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "bytes" + "mime/multipart" + "net/textproto" + "testing" +) + +type seekBuf struct { + *bytes.Reader +} + +func (s seekBuf) Close() error { return nil } + +func TestAcceptAdminVideoMP4(t *testing.T) { + // Minimal ftyp box header + payload := []byte{ + 0x00, 0x00, 0x00, 0x18, 'f', 't', 'y', 'p', + 'i', 's', 'o', 'm', 0x00, 0x00, 0x02, 0x00, + 'i', 's', 'o', 'm', 'i', 's', 'o', '2', + } + h := &multipart.FileHeader{ + Filename: "lesson.mp4", + Size: int64(len(payload)), + Header: textproto.MIMEHeader{"Content-Type": []string{"video/mp4"}}, + } + got, err := acceptAdminVideo(h, seekBuf{bytes.NewReader(payload)}) + if err != nil { + t.Fatal(err) + } + if got.Ext != ".mp4" || got.Mime != "video/mp4" { + t.Fatalf("got %+v", got) + } +} + +func TestAcceptAdminVideoWebM(t *testing.T) { + payload := []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + h := &multipart.FileHeader{ + Filename: "clip.webm", + Size: int64(len(payload)), + Header: textproto.MIMEHeader{"Content-Type": []string{"video/webm"}}, + } + got, err := acceptAdminVideo(h, seekBuf{bytes.NewReader(payload)}) + if err != nil { + t.Fatal(err) + } + if got.Ext != ".webm" || got.Mime != "video/webm" { + t.Fatalf("got %+v", got) + } +} + +func TestAcceptAdminVideoRejectsMOV(t *testing.T) { + payload := []byte{0x00, 0x00, 0x00, 0x14, 'f', 't', 'y', 'p', 'q', 't', ' ', ' '} + h := &multipart.FileHeader{ + Filename: "clip.mov", + Size: int64(len(payload)), + Header: textproto.MIMEHeader{"Content-Type": []string{"video/quicktime"}}, + } + if _, err := acceptAdminVideo(h, seekBuf{bytes.NewReader(payload)}); err == nil { + t.Fatal("expected rejection") + } +} + +func TestAcceptAdminVideoRejectsMismatch(t *testing.T) { + payload := []byte{0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x02, 0x03, 0x04} + h := &multipart.FileHeader{ + Filename: "clip.mp4", + Size: int64(len(payload)), + Header: textproto.MIMEHeader{"Content-Type": []string{"video/mp4"}}, + } + if _, err := acceptAdminVideo(h, seekBuf{bytes.NewReader(payload)}); err == nil { + t.Fatal("expected content mismatch rejection") + } +} diff --git a/backend/internal/api/whitelist.go b/backend/internal/api/whitelist.go new file mode 100644 index 0000000..13c1782 --- /dev/null +++ b/backend/internal/api/whitelist.go @@ -0,0 +1,132 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "pargar/backend/internal/observability" + "pargar/backend/internal/store" +) + +type importWhitelistRequest struct { + Phones []string `json:"phones"` + Text string `json:"text"` + ResolveConflicts bool `json:"resolveConflicts"` + Force bool `json:"force"` +} + +func parseWhitelistPhones(req importWhitelistRequest) (valid []string, invalid int) { + seen := make(map[string]struct{}) + add := func(raw string) { + phone := normalizePhone(raw) + if phone == "" { + return + } + if !validPhone(phone) { + invalid++ + return + } + if _, ok := seen[phone]; ok { + return + } + seen[phone] = struct{}{} + valid = append(valid, phone) + } + for _, p := range req.Phones { + add(p) + } + if req.Text != "" { + for _, line := range strings.FieldsFunc(req.Text, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' || r == '\t' + }) { + add(strings.TrimSpace(line)) + } + } + return valid, invalid +} + +func (s *Server) handleAdminImportWhitelist(w http.ResponseWriter, r *http.Request) { + var req importWhitelistRequest + if err := bodyJSON(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + phones, invalid := parseWhitelistPhones(req) + if len(phones) == 0 { + writeErr(w, http.StatusBadRequest, "هیچ شمارهٔ معتبری ارسال نشده است") + return + } + if len(phones) > 5000 { + writeErr(w, http.StatusBadRequest, "حداکثر ۵۰۰۰ شماره در هر واردات مجاز است") + return + } + admin := currentUser(r) + resolve := req.ResolveConflicts || req.Force + result, err := s.store.ImportWhitelistPhones(r.Context(), phones, admin.ID, resolve) + if err != nil { + writeErr(w, http.StatusInternalServerError, "واردات فهرست شماره‌ها ممکن نشد") + return + } + conflicts := result.BlacklistedConflicts + if conflicts == nil { + conflicts = []string{} + } + rejectedPhones := result.RejectedPhones + if rejectedPhones == nil { + rejectedPhones = []string{} + } + observability.Activity("admin.whitelist_import", + "inserted", result.Inserted, "skipped", result.Skipped, + "rejected", result.Rejected, + "blacklisted_conflicts", len(conflicts), "removed_blacklist", result.RemovedBlacklist, + "invalid", invalid, "resolve", resolve, "admin_id", admin.ID) + writeJSON(w, http.StatusOK, map[string]any{ + "inserted": result.Inserted, + "skipped": result.Skipped, + "rejected": result.Rejected, + "rejectedPhones": rejectedPhones, + "invalid": invalid, + "total": len(phones), + "blacklistedConflicts": conflicts, + "removedBlacklist": result.RemovedBlacklist, + }) +} + +func (s *Server) handleAdminListWhitelist(w http.ResponseWriter, r *http.Request) { + status := strings.TrimSpace(r.URL.Query().Get("status")) + q := strings.TrimSpace(r.URL.Query().Get("q")) + if digits := normalizePhone(q); digits != "" { + q = digits + } + p := parsePageParams(r) + entries, total, err := s.store.ListWhitelistPhones(r.Context(), status, q, p.PageSize, p.Offset) + if err != nil { + writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست شماره‌ها ممکن نشد") + return + } + writePage(w, entries, total, p) +} + +func (s *Server) handleAdminDeleteWhitelist(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id < 1 { + writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") + return + } + if err := s.store.DeleteAvailableWhitelistPhone(r.Context(), id); err != nil { + if errors.Is(err, store.ErrWhitelistNotFound) { + writeErr(w, http.StatusNotFound, "شماره در فهرست یافت نشد") + return + } + if errors.Is(err, store.ErrWhitelistNotAvailable) { + writeErr(w, http.StatusConflict, "فقط شماره‌های استفاده‌نشده قابل حذف هستند") + return + } + writeErr(w, http.StatusInternalServerError, "حذف شماره ممکن نشد") + return + } + observability.Activity("admin.whitelist_delete", "id", id, "admin_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/backend/internal/api/whitelist_test.go b/backend/internal/api/whitelist_test.go new file mode 100644 index 0000000..def264e --- /dev/null +++ b/backend/internal/api/whitelist_test.go @@ -0,0 +1,321 @@ +package api_test + +import ( + "fmt" + "net/http" + "testing" +) + +func TestWhitelistImportListDelete(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "text": "09123330001\n09123330002,09123330001\n12345", + }) + if code != http.StatusOK { + t.Fatalf("import: %d %v", code, body) + } + if body["inserted"] != float64(2) { + t.Fatalf("inserted: %v", body["inserted"]) + } + if body["invalid"] != float64(1) { + t.Fatalf("invalid: %v", body["invalid"]) + } + + code, body = e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": []string{"09123330001", "09123330003"}, + }) + if code != http.StatusOK { + t.Fatalf("reimport: %d %v", code, body) + } + if body["inserted"] != float64(1) || body["skipped"] != float64(1) { + t.Fatalf("expected insert1 skip1, got %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("list: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) != 3 { + t.Fatalf("expected 3 available, got %d", len(entries)) + } + if body["total"] != float64(3) { + t.Fatalf("total: %v", body["total"]) + } + id := int64(entries[0].(map[string]any)["id"].(float64)) + + code, body = e.do(t, "DELETE", "/api/admin/phone-whitelist/"+itoa(id), admin, nil) + if code != http.StatusOK { + t.Fatalf("delete: %d %v", code, body) + } + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("list after delete: %d %v", code, body) + } + if len(body["items"].([]any)) != 2 { + t.Fatalf("expected 2 after delete, got %v", body) + } +} + +func TestInviteRegisterWithoutWhitelistOK(t *testing.T) { + e := setup(t) + inv := createInvite(t, e, 2, "", nil) + token := inv["token"].(string) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.201", + }, registerBody(t, e, "09124440001", "inv-nowl@test.dev", "invnowl")) + if code != http.StatusCreated { + t.Fatalf("invite without whitelist should work: %d %v", code, body) + } +} + +func TestInviteRegisterConsumesWhitelist(t *testing.T) { + e := setup(t) + whitelistPhones(t, e, "09124440010") + inv := createInvite(t, e, 2, "", nil) + token := inv["token"].(string) + + code, body := e.doHeaders(t, "POST", "/api/auth/register-invite/"+token, "", map[string]string{ + "X-Forwarded-For": "203.0.113.202", + }, registerBody(t, e, "09124440010", "inv-wl@test.dev", "invwl")) + if code != http.StatusCreated { + t.Fatalf("invite register: %d %v", code, body) + } + + enableRegistration(t, e) + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.203", + }, registerBody(t, e, "09124440010", "pub-after-inv@test.dev", "pubafterinv")) + if code != http.StatusConflict { + t.Fatalf("expected conflict after invite consumed phone, got %d %v", code, body) + } + + admin := login(t, e, bootstrapAdminEmail) + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?status=consumed", admin, nil) + if code != http.StatusOK { + t.Fatalf("list consumed: %d %v", code, body) + } + entries := body["items"].([]any) + if len(entries) != 1 { + t.Fatalf("expected consumed entry, got %v", body) + } + if entries[0].(map[string]any)["phone"] != "09124440010" { + t.Fatalf("phone: %v", entries[0]) + } +} + +func TestPublicRegisterAfterWhitelistConsumeFails(t *testing.T) { + e := setup(t) + enableRegistration(t, e) + whitelistPhones(t, e, "09124440020") + + code, body := e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.210", + }, registerBody(t, e, "09124440020", "wl-once@test.dev", "wlonce")) + if code != http.StatusCreated { + t.Fatalf("first: %d %v", code, body) + } + + // Re-import same phone is skipped (already exists as consumed), so second public + // attempt hits phone uniqueness — still blocked. + code, body = e.doHeaders(t, "POST", "/api/auth/register", "", map[string]string{ + "X-Forwarded-For": "203.0.113.211", + }, registerBody(t, e, "09124440020", "wl-twice@test.dev", "wltwice")) + if code != http.StatusConflict { + t.Fatalf("expected second use blocked, got %d %v", code, body) + } +} + +func TestWhitelistImportBlockedWhenBlacklistedUntilForce(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09123330100"}, + }) + if code != http.StatusOK { + t.Fatalf("blacklist seed: %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": []string{"09123330100", "09123330101"}, + }) + if code != http.StatusOK { + t.Fatalf("whitelist import: %d %v", code, body) + } + if body["inserted"] != float64(1) { + t.Fatalf("inserted without resolve: %v", body["inserted"]) + } + conflicts, ok := body["blacklistedConflicts"].([]any) + if !ok || len(conflicts) != 1 || conflicts[0] != "09123330100" { + t.Fatalf("blacklistedConflicts: %v", body["blacklistedConflicts"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist", admin, nil) + if code != http.StatusOK { + t.Fatalf("blacklist list: %d %v", code, body) + } + if body["total"] != float64(1) { + t.Fatalf("expected blacklist intact, got %v", body) + } + + code, body = e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": []string{"09123330100"}, + "resolveConflicts": true, + }) + if code != http.StatusOK { + t.Fatalf("force whitelist import: %d %v", code, body) + } + if body["inserted"] != float64(1) { + t.Fatalf("inserted: %v", body["inserted"]) + } + if body["removedBlacklist"] != float64(1) { + t.Fatalf("removedBlacklist: %v", body["removedBlacklist"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-blacklist", admin, nil) + if code != http.StatusOK { + t.Fatalf("blacklist after force: %d %v", code, body) + } + if body["total"] != float64(0) { + t.Fatalf("expected blacklist cleared, got %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?q=09123330100", admin, nil) + if code != http.StatusOK { + t.Fatalf("whitelist after force: %d %v", code, body) + } + if body["total"] != float64(1) { + t.Fatalf("expected phone on whitelist, got %v", body) + } +} + +func TestWhitelistImportForceAlias(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + code, body := e.do(t, "POST", "/api/admin/phone-blacklist/import", admin, map[string]any{ + "phones": []string{"09123330110"}, + }) + if code != http.StatusOK { + t.Fatalf("blacklist seed: %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": []string{"09123330110"}, + "force": true, + }) + if code != http.StatusOK { + t.Fatalf("force import: %d %v", code, body) + } + if body["inserted"] != float64(1) || body["removedBlacklist"] != float64(1) { + t.Fatalf("force result: %v", body) + } +} + +func TestWhitelistListPagination(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + phones := make([]string, 0, 25) + for i := 1; i <= 25; i++ { + phones = append(phones, fmt.Sprintf("0912334%04d", i)) + } + code, body := e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": phones, + }) + if code != http.StatusOK { + t.Fatalf("import: %d %v", code, body) + } + if body["inserted"] != float64(25) { + t.Fatalf("inserted: %v", body["inserted"]) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?page=1&pageSize=10&status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("page1: %d %v", code, body) + } + items := body["items"].([]any) + if len(items) != 10 { + t.Fatalf("page1 len: %d", len(items)) + } + if body["total"] != float64(25) || body["page"] != float64(1) || body["pageSize"] != float64(10) { + t.Fatalf("page1 meta: %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?page=3&pageSize=10&status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("page3: %d %v", code, body) + } + items = body["items"].([]any) + if len(items) != 5 { + t.Fatalf("page3 len: %d", len(items)) + } + if body["total"] != float64(25) || body["page"] != float64(3) { + t.Fatalf("page3 meta: %v", body) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?page=1&pageSize=500&status=available", admin, nil) + if code != http.StatusOK { + t.Fatalf("capped: %d %v", code, body) + } + if body["pageSize"] != float64(100) { + t.Fatalf("expected pageSize capped to 100, got %v", body["pageSize"]) + } + if len(body["items"].([]any)) != 25 { + t.Fatalf("capped list len: %d", len(body["items"].([]any))) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?page=1&pageSize=10&q=09123340001", admin, nil) + if code != http.StatusOK { + t.Fatalf("search: %d %v", code, body) + } + if body["total"] != float64(1) { + t.Fatalf("search total: %v", body["total"]) + } + items = body["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["phone"] != "09123340001" { + t.Fatalf("search items: %v", items) + } + + code, body = e.do(t, "GET", "/api/admin/phone-whitelist?page=1", admin, nil) + if code != http.StatusOK { + t.Fatalf("default pageSize: %d %v", code, body) + } + if body["pageSize"] != float64(10) { + t.Fatalf("expected default pageSize 10, got %v", body["pageSize"]) + } +} + +func TestWhitelistImportRejectsExistingUserPhone(t *testing.T) { + e := setup(t) + admin := login(t, e, bootstrapAdminEmail) + + code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ + "name": "کاربر WL", "email": "wl-exist@test.dev", "username": "wlexist", + "password": "password123", "role": "student", + "phone": "09123330999", "securityQuestion": "رنگ؟", "securityAnswer": "آبی", + }) + if code != http.StatusCreated { + t.Fatalf("create user: %d %v", code, body) + } + + code, body = e.do(t, "POST", "/api/admin/phone-whitelist/import", admin, map[string]any{ + "phones": []string{"09123330999", "09123330998"}, + }) + if code != http.StatusOK { + t.Fatalf("whitelist import: %d %v", code, body) + } + if body["inserted"] != float64(1) { + t.Fatalf("inserted: %v", body["inserted"]) + } + if body["rejected"] != float64(1) { + t.Fatalf("rejected: %v", body["rejected"]) + } + rejected, _ := body["rejectedPhones"].([]any) + if len(rejected) != 1 || rejected[0] != "09123330999" { + t.Fatalf("rejectedPhones: %v", body["rejectedPhones"]) + } +} diff --git a/backend/internal/api/ws.go b/backend/internal/api/ws.go new file mode 100644 index 0000000..4598408 --- /dev/null +++ b/backend/internal/api/ws.go @@ -0,0 +1,185 @@ +package api + +import ( + "context" + "encoding/json" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" +) + +const pushChannel = "pargar:push" + +// Hub manages per-user websocket connections and fans out messages. +// Messages are published on a shared Redis channel so all instances deliver to their own local clients. +type Hub struct { + mu sync.RWMutex + conns map[int64]map[*Client]struct{} + rdb *redis.Client +} + +type Client struct { + hub *Hub + conn *websocket.Conn + user int64 + send chan []byte + once sync.Once +} + +func NewHub(rdb *redis.Client) *Hub { + h := &Hub{ + conns: map[int64]map[*Client]struct{}{}, + rdb: rdb, + } + go h.subscribe() + return h +} + +func (h *Hub) subscribe() { + ctx := context.Background() + sub := h.rdb.Subscribe(ctx, pushChannel) + defer sub.Close() + ch := sub.Channel() + for msg := range ch { + var p struct { + User int64 `json:"user"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal([]byte(msg.Payload), &p); err != nil { + continue + } + h.deliver(p.User, p.Data) + } +} + +// Push fans a message out to a user across all instances. +func (h *Hub) Push(userID int64, msg any) { + b, err := json.Marshal(msg) + if err != nil { + return + } + payload, _ := json.Marshal(map[string]any{"user": userID, "data": json.RawMessage(b)}) + h.rdb.Publish(context.Background(), pushChannel, payload) +} + +func (h *Hub) deliver(userID int64, data []byte) { + h.mu.RLock() + clients := h.conns[userID] + for c := range clients { + select { + case c.send <- data: + default: + } + } + h.mu.RUnlock() +} + +// Online reports whether a user currently has at least one live connection. +func (h *Hub) Online(userID int64) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.conns[userID]) > 0 +} + +// Register adds a new client connection for a user. +func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client { + c := &Client{hub: h, conn: conn, user: userID, send: make(chan []byte, 16)} + h.mu.Lock() + if h.conns[userID] == nil { + h.conns[userID] = map[*Client]struct{}{} + } + h.conns[userID][c] = struct{}{} + h.mu.Unlock() + go c.writePump() + go c.readPump() + return c +} + +func (h *Hub) Unregister(c *Client) { + h.mu.Lock() + if set, ok := h.conns[c.user]; ok { + if _, ok := set[c]; ok { + delete(set, c) + if len(set) == 0 { + delete(h.conns, c.user) + } + } + } + h.mu.Unlock() + close(c.send) +} + +// close unregisters the client and closes its connection exactly once, +// even though both readPump and writePump may return independently. +func (c *Client) close() { + c.once.Do(func() { + c.hub.Unregister(c) + c.conn.Close() + }) +} + +func (c *Client) writePump() { + defer func() { + if r := recover(); r != nil { + log.Printf("ws writePump recovered user=%d: %v", c.user, r) + } + }() + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + c.close() + }() + for { + select { + case msg, ok := <-c.send: + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (c *Client) readPump() { + defer func() { + if r := recover(); r != nil { + log.Printf("ws readPump recovered user=%d: %v", c.user, r) + } + }() + defer c.close() + c.conn.SetReadLimit(4096) + c.conn.SetReadDeadline(time.Now().Add(70 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(70 * time.Second)) + return nil + }) + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + // close errors are expected; log genuinely unexpected ones only. + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("ws read err user=%d: %v", c.user, err) + } + return + } + var incoming struct { + Type string `json:"type"` + To int64 `json:"to"` + } + if json.Unmarshal(data, &incoming) == nil && incoming.Type == "typing" && incoming.To > 0 { + c.hub.Push(incoming.To, map[string]any{"type": "typing", "from": c.user}) + } + } +} diff --git a/backend/internal/api/ws_handler.go b/backend/internal/api/ws_handler.go new file mode 100644 index 0000000..b7a780e --- /dev/null +++ b/backend/internal/api/ws_handler.go @@ -0,0 +1,80 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/gorilla/websocket" +) + +const wsBearerProtocol = "bearer" + +// extractWSToken resolves the access token without putting it in the query string. +// Preference order: Authorization Bearer → Sec-WebSocket-Protocol → httpOnly cookie. +func extractWSToken(r *http.Request) (token string, selectedProtocol string) { + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")), "" + } + + protocols := websocket.Subprotocols(r) + for i, p := range protocols { + p = strings.TrimSpace(p) + if p == wsBearerProtocol && i+1 < len(protocols) { + next := strings.TrimSpace(protocols[i+1]) + if next != "" { + return next, wsBearerProtocol + } + } + if strings.HasPrefix(p, wsBearerProtocol+".") { + tok := strings.TrimPrefix(p, wsBearerProtocol+".") + if tok != "" { + // Negotiate the bare "bearer" name so the JWT is not echoed in the response. + return tok, wsBearerProtocol + } + } + } + + if c, err := r.Cookie(cookieAccess); err == nil && c.Value != "" { + return c.Value, "" + } + + // Query-string tokens are rejected — JWTs in URLs end up in proxy/access logs. + return "", "" +} + +// handleWS upgrades to a websocket authenticated via Bearer header or +// Sec-WebSocket-Protocol (bearer + token). +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { + if !s.allowWSHandshake(r) { + w.Header().Set("Retry-After", "60") + writeErr(w, http.StatusTooManyRequests, authRateMsg) + return + } + + token, selectedProtocol := extractWSToken(r) + if token == "" { + writeErr(w, http.StatusUnauthorized, "توکن ارسال نشده است") + return + } + user, err := s.auth.parseAndLoad(r.Context(), token) + if err != nil { + writeErr(w, http.StatusUnauthorized, "توکن نامعتبر است") + return + } + if meta, ok := r.Context().Value(ctxMeta).(*requestMeta); ok && meta != nil { + meta.UserID = user.ID + } + + upgrader := s.newUpgrader() + if selectedProtocol != "" { + upgrader.Subprotocols = []string{selectedProtocol} + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := s.hub.Register(user.ID, conn) + + // send a welcome frame so clients know the socket is live + c.send <- []byte(`{"type":"connected"}`) +} diff --git a/backend/internal/api/ws_test.go b/backend/internal/api/ws_test.go new file mode 100644 index 0000000..8102d62 --- /dev/null +++ b/backend/internal/api/ws_test.go @@ -0,0 +1,97 @@ +package api_test + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + "pargar/backend/internal/api" +) + +func TestWebSocketUpgrade(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("parse url: %v", err) + } + u.Scheme = "ws" + u.Path = "/api/ws" + + dialer := websocket.Dialer{ + HandshakeTimeout: 3 * time.Second, + Subprotocols: []string{"bearer", token}, + } + conn, resp, err := dialer.Dial(u.String(), nil) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("websocket dial: status=%d err=%v", status, err) + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected 101 Switching Protocols, got %d", resp.StatusCode) + } + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read welcome: %v", err) + } + if !strings.Contains(string(msg), `"connected"`) { + t.Fatalf("unexpected welcome frame: %s", msg) + } +} + +func TestWebSocketExcludedFromGlobalRateLimit(t *testing.T) { + e := setup(t) + token := login(t, e, bootstrapAdminEmail) + + prev := api.SetAPIGlobalLimitForTest(2) + t.Cleanup(func() { api.SetAPIGlobalLimitForTest(prev) }) + + // Burn the global REST budget from a fixed IP. + for i := 0; i < 6; i++ { + req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Forwarded-For", "198.51.100.77") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("me: %v", err) + } + _ = resp.Body.Close() + } + + u, err := url.Parse(e.server.URL) + if err != nil { + t.Fatalf("parse url: %v", err) + } + u.Scheme = "ws" + u.Path = "/api/ws" + + header := http.Header{} + header.Set("X-Forwarded-For", "198.51.100.77") + dialer := websocket.Dialer{ + HandshakeTimeout: 3 * time.Second, + Subprotocols: []string{"bearer", token}, + } + conn, resp, err := dialer.Dial(u.String(), header) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("ws must not be rate-limited: status=%d err=%v", status, err) + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected 101, got %d", resp.StatusCode) + } +}