feat(backend): implement HTTP API, auth, chat, and admin surfaces
Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
This commit is contained in:
@@ -0,0 +1,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)})
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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<<uint(4-col)) == 0 {
|
||||
continue
|
||||
}
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
x := ox + col*scale + dx
|
||||
y := oy + row*scale + dy
|
||||
if x >= 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, "پاسخ کد امنیتی نادرست یا منقضی است"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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+"-")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "درخواست برای تیم ارسال شد. میتوانید همزمان با منتور هم گفتگو کنید.",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "بازیابی رمز عمومی غیرفعال است")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("این نوع فایل مجاز نیست")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package api
|
||||
|
||||
import "strconv"
|
||||
|
||||
func strconvFormatInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -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 <a@b.c>".
|
||||
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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user