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:
ahp
2026-10-07 19:40:26 +03:30
parent 3ee609337c
commit 422e6ed289
58 changed files with 11756 additions and 0 deletions
+890
View File
@@ -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)})
}
+295
View File
@@ -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:])
}
+342
View File
@@ -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)})
}
+155
View File
@@ -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
}
+349
View File
@@ -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"])
}
}
+350
View File
@@ -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, "پاسخ کد امنیتی نادرست یا منقضی است"
}
}
+134
View File
@@ -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)
}
}
}
+288
View File
@@ -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
}
}
+108
View File
@@ -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 ""
}
+512
View File
@@ -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)
}
+619
View File
@@ -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)
}
}
+398
View File
@@ -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
}
+65
View File
@@ -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
}
+31
View File
@@ -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")
}
}
+103
View File
@@ -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)
}
+63
View File
@@ -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())
}
}
+130
View File
@@ -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
}
+101
View File
@@ -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")
}
}
+602
View File
@@ -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"])
}
}
+125
View File
@@ -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[:])
}
+223
View File
@@ -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)
}
}
+464
View File
@@ -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})
}
+390
View File
@@ -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)
}
}
+94
View File
@@ -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},
})
}
+42
View File
@@ -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})
}
+96
View File
@@ -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+"-")
}
+76
View File
@@ -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
}
+91
View File
@@ -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)
}
+97
View File
@@ -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})
}
+66
View File
@@ -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,
})
}
+115
View File
@@ -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)
}
}
+369
View File
@@ -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": "درخواست برای تیم ارسال شد. می‌توانید همزمان با منتور هم گفتگو کنید.",
})
}
+301
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+16
View File
@@ -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, "بازیابی رمز عمومی غیرفعال است")
}
+80
View File
@@ -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()
}
+180
View File
@@ -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,
})
}
+307
View File
@@ -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)
}
}
+25
View File
@@ -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)
}
}
}
+154
View File
@@ -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
}
+127
View File
@@ -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)
}
+246
View File
@@ -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
}
+228
View File
@@ -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 }
}
+77
View File
@@ -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 ""
}
+178
View File
@@ -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)
}
+283
View File
@@ -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
}
+219
View File
@@ -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("این نوع فایل مجاز نیست")
}
}
+126
View File
@@ -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")
}
}
+7
View File
@@ -0,0 +1,7 @@
package api
import "strconv"
func strconvFormatInt(v int64) string {
return strconv.FormatInt(v, 10)
}
+228
View File
@@ -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 ""
}
+88
View File
@@ -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)
}
}
+109
View File
@@ -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
}
+75
View File
@@ -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")
}
}
+132
View File
@@ -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})
}
+321
View File
@@ -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"])
}
}
+185
View File
@@ -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})
}
}
}
+80
View File
@@ -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"}`)
}
+97
View File
@@ -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)
}
}