feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
This commit is contained in:
@@ -22,6 +22,20 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if id <= 0 {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
u, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": u})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
role := r.URL.Query().Get("role")
|
||||
@@ -389,27 +403,17 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SecurityAnswer string `json:"securityAnswer"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
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
|
||||
|
||||
@@ -306,6 +306,7 @@ func (s *Server) handlePutAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی آواتار ممکن نشد")
|
||||
return
|
||||
}
|
||||
s.signUserMedia(user)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaTTL = 2 * time.Minute
|
||||
captchaKeyPrefix = "pargar:captcha:"
|
||||
captchaRateLimit int64 = 60
|
||||
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
@@ -260,6 +260,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"challengeId": "dev",
|
||||
"imageBase64": img,
|
||||
"expiresIn": int(captchaTTL.Seconds()),
|
||||
}
|
||||
if s.cfg.CaptchaExposeAnswer {
|
||||
resp["answer"] = "A2B3"
|
||||
@@ -297,6 +298,7 @@ func (s *Server) handleCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"challengeId": id,
|
||||
"imageBase64": img,
|
||||
"expiresIn": int(captchaTTL.Seconds()),
|
||||
}
|
||||
// Never expose plaintext answers in production. Local tests may opt in via
|
||||
// CAPTCHA_EXPOSE_ANSWER=true combined with DEV_MODE=true.
|
||||
|
||||
@@ -30,6 +30,10 @@ func TestCaptchaImageChallenge(t *testing.T) {
|
||||
if id == "" || img == "" || answer == "" {
|
||||
t.Fatalf("captcha missing fields: %v", body)
|
||||
}
|
||||
exp, ok := body["expiresIn"].(float64)
|
||||
if !ok || exp < 60 || exp > 180 {
|
||||
t.Fatalf("expiresIn should be a short window: %v", body["expiresIn"])
|
||||
}
|
||||
if _, ok := body["question"]; ok {
|
||||
t.Fatalf("legacy question field should be gone: %v", body)
|
||||
}
|
||||
|
||||
@@ -228,12 +228,22 @@ func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http
|
||||
|
||||
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
|
||||
p := parsePageParams(r)
|
||||
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارشها ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "physical": s.physicalSettings(r.Context())})
|
||||
if orders == nil {
|
||||
orders = []models.CertificatePhysicalOrder{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"orders": orders,
|
||||
"total": total,
|
||||
"page": p.Page,
|
||||
"pageSize": p.PageSize,
|
||||
"physical": s.physicalSettings(r.Context()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -37,6 +37,7 @@ func (s *Server) handleListMentors(w http.ResponseWriter, r *http.Request) {
|
||||
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
|
||||
continue
|
||||
}
|
||||
s.signUserMedia(&m)
|
||||
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,
|
||||
@@ -111,10 +112,16 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
photo := p.AvatarPhoto
|
||||
if photo != "" {
|
||||
tmp := &models.User{ID: p.ID, AvatarPhoto: photo}
|
||||
s.signUserMedia(tmp)
|
||||
photo = tmp.AvatarPhoto
|
||||
}
|
||||
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,
|
||||
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
|
||||
"online": s.hub.Online(p.ID),
|
||||
},
|
||||
UnreadCount: unread,
|
||||
@@ -137,6 +144,7 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||
return
|
||||
}
|
||||
_ = s.markIncomingChatRead(r.Context(), u, partner)
|
||||
|
||||
if raw := r.URL.Query().Get("around"); raw != "" {
|
||||
aroundID, err := strconv.ParseInt(raw, 10, 64)
|
||||
@@ -156,7 +164,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -197,7 +204,6 @@ func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
_ = 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 {
|
||||
@@ -337,6 +343,73 @@ func (s *Server) handleChatReact(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "emoji": emoji, "reactions": reactions})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatEdit(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 {
|
||||
Body string `json:"body"`
|
||||
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 len(req.Body) > 4000 {
|
||||
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
msg *models.ChatMessage
|
||||
err error
|
||||
)
|
||||
if req.Attachment != nil {
|
||||
att := req.Attachment
|
||||
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
|
||||
if att.Type != "image" && att.Type != "video" && att.Type != "audio" {
|
||||
writeErr(w, http.StatusBadRequest, "فقط عکس، ویدیو یا صدا را میتوان جایگزین کرد")
|
||||
return
|
||||
}
|
||||
key := mediaKeyFromURL(att.URL)
|
||||
if key == "" || !mediaKeyOwnedByUser(key, u.ID) {
|
||||
writeErr(w, http.StatusBadRequest, "پیوست متعلق به شما نیست")
|
||||
return
|
||||
}
|
||||
if q := strings.Index(att.URL, "?"); q >= 0 {
|
||||
att.URL = att.URL[:q]
|
||||
}
|
||||
if att.Name == "" {
|
||||
att.Name = "file"
|
||||
}
|
||||
msg, err = s.store.ReplaceChatAttachment(r.Context(), u.ID, partner, msgID, u.Role, req.Body, att.Type, att.URL, att.Name, att.Size)
|
||||
} else {
|
||||
if req.Body == "" {
|
||||
writeErr(w, http.StatusBadRequest, "پیام باید بین ۱ تا ۴۰۰۰ نویسه باشد")
|
||||
return
|
||||
}
|
||||
msg, err = s.store.EditChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body)
|
||||
}
|
||||
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_edit", "item": msg, "from": u.ID}
|
||||
s.hub.Push(u.ID, event)
|
||||
s.hub.Push(partner, event)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatDelete(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
partner := routeID(r, "partner")
|
||||
@@ -473,13 +546,25 @@ func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkChatRead(r.Context(), u.ID, partner); err != nil {
|
||||
if err := s.markIncomingChatRead(r.Context(), u, partner); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت خواندهشدن ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User, partnerID int64) error {
|
||||
if reader == nil {
|
||||
return nil
|
||||
}
|
||||
n, err := s.store.MarkChatRead(ctx, reader.ID, partnerID, reader.Role)
|
||||
if err != nil || n == 0 {
|
||||
return err
|
||||
}
|
||||
s.hub.Push(partnerID, map[string]any{"type": "chat_read", "from": reader.ID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
|
||||
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
|
||||
return false
|
||||
|
||||
@@ -57,6 +57,7 @@ type chatExportMessage struct {
|
||||
ReplyTo *int64 `json:"replyTo,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
||||
EditedAt *time.Time `json:"editedAt,omitempty"`
|
||||
Attachment *chatExportAttachment `json:"attachment,omitempty"`
|
||||
}
|
||||
|
||||
@@ -183,6 +184,7 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
|
||||
ReplyTo: m.ReplyTo,
|
||||
Pinned: m.PinnedAt != nil,
|
||||
PinnedAt: m.PinnedAt,
|
||||
EditedAt: m.EditedAt,
|
||||
}
|
||||
if m.Attachment != nil {
|
||||
att := &chatExportAttachment{
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestUniquePhone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
|
||||
func TestAdminResetPassword(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
|
||||
@@ -59,14 +59,14 @@ func TestAdminResetPasswordRequiresSecurityAnswer(t *testing.T) {
|
||||
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",
|
||||
"newPassword": "short",
|
||||
})
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("wrong answer should 403, got %d", code)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("short password: %d", code)
|
||||
}
|
||||
|
||||
code, body = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/reset-password", admin, map[string]any{
|
||||
"securityAnswer": "تهران", "newPassword": "newpass123",
|
||||
"newPassword": "newpass123",
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("reset: %d %v", code, body)
|
||||
|
||||
@@ -284,8 +284,15 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleAdminListInvites(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
switch models.InviteStatus(status) {
|
||||
case "", models.InviteActive, models.InvitePaused, models.InviteRevoked, models.InviteExhausted:
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است")
|
||||
return
|
||||
}
|
||||
p := parsePageParams(r)
|
||||
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, p.PageSize, p.Offset)
|
||||
invites, total, err := s.store.ListRegistrationInvites(r.Context(), q, status, p.PageSize, p.Offset)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری لینکهای عضویت ممکن نشد")
|
||||
return
|
||||
|
||||
@@ -63,6 +63,7 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.signUserMedia(usr)
|
||||
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,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"pargar/backend/internal/observability"
|
||||
"pargar/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleLearningOverview(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -21,12 +23,25 @@ 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)
|
||||
filter := r.URL.Query().Get("filter")
|
||||
switch filter {
|
||||
case "active", "locked":
|
||||
default:
|
||||
filter = ""
|
||||
}
|
||||
sort := r.URL.Query().Get("sort")
|
||||
switch sort {
|
||||
case "progress", "streak", "activity":
|
||||
default:
|
||||
sort = "xp"
|
||||
}
|
||||
p := parsePageParams(r)
|
||||
rows, total, err := s.store.ListStudentLearning(ctx, q, filter, sort, p.PageSize, p.Offset)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری فهرست هنرجویان ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"students": rows})
|
||||
writePage(w, rows, total, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -34,9 +49,13 @@ func (s *Server) handleUserLearning(w http.ResponseWriter, r *http.Request) {
|
||||
defer span.End()
|
||||
id := routeID(r, "id")
|
||||
detail, err := s.store.UserLearningDetail(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری جزئیات یادگیری ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"learning": detail})
|
||||
}
|
||||
|
||||
@@ -13,12 +13,28 @@ import (
|
||||
"pargar/backend/internal/models"
|
||||
)
|
||||
|
||||
// mediaSignWindow keeps one signature for a stretch of time. The same object
|
||||
// then keeps the same URL, so clients do not reload a photo that did not change.
|
||||
const mediaSignWindow = 15 * time.Minute
|
||||
|
||||
func stableMediaExpiry(now time.Time, ttl, window time.Duration) int64 {
|
||||
if window <= 0 {
|
||||
window = mediaSignWindow
|
||||
}
|
||||
if ttl < window {
|
||||
ttl = window
|
||||
}
|
||||
w := int64(window.Seconds())
|
||||
slot := now.Unix() / w
|
||||
return (slot+1)*w + int64(ttl.Seconds())
|
||||
}
|
||||
|
||||
func (s *Server) signMediaURL(key string) string {
|
||||
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
exp := time.Now().Add(s.cfg.MediaURLTTL).Unix()
|
||||
exp := stableMediaExpiry(time.Now(), s.cfg.MediaURLTTL, mediaSignWindow)
|
||||
sig := mediaHMAC(s.cfg.JWTSecret, key, exp)
|
||||
return fmt.Sprintf("%s/media/%s?exp=%d&sig=%s",
|
||||
s.cfg.PublicURL,
|
||||
|
||||
@@ -7,6 +7,24 @@ import (
|
||||
"pargar/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestStableMediaExpiryHoldsInsideWindow(t *testing.T) {
|
||||
start := time.Unix((1_700_000_000/900)*900, 0)
|
||||
ttl := time.Hour
|
||||
window := 15 * time.Minute
|
||||
a := stableMediaExpiry(start, ttl, window)
|
||||
b := stableMediaExpiry(start.Add(14*time.Minute), ttl, window)
|
||||
if a != b {
|
||||
t.Fatalf("same window should keep one expiry: %d vs %d", a, b)
|
||||
}
|
||||
c := stableMediaExpiry(start.Add(15*time.Minute), ttl, window)
|
||||
if c == a {
|
||||
t.Fatal("next window should mint a new expiry")
|
||||
}
|
||||
if c < a {
|
||||
t.Fatal("expiry should move forward")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"pargar/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if id <= 0 {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
u, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
|
||||
return
|
||||
}
|
||||
s.signUserMedia(u)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": u.ID,
|
||||
"name": u.Name,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
"xp": u.XP,
|
||||
"hearts": u.Hearts,
|
||||
"streakCurrent": u.StreakCurrent,
|
||||
"streakLongest": u.StreakLongest,
|
||||
"avatarVariant": u.AvatarVariant,
|
||||
"avatarPalette": u.AvatarPalette,
|
||||
"avatarPhoto": u.AvatarPhoto,
|
||||
"isLocked": u.IsLocked,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
// Public self-service password recovery is disabled for students.
|
||||
// Staff reset passwords from the admin panel (requires the account security answer).
|
||||
// Staff set a new password from the admin profile. Public recovery stays off.
|
||||
|
||||
func (s *Server) handleRecoveryChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, "بازیابی رمز عمومی غیرفعال است")
|
||||
|
||||
@@ -56,7 +56,9 @@ func newRouter(s *Server) http.Handler {
|
||||
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("PUT /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatEdit))
|
||||
mux.HandleFunc("DELETE /api/chats/{partner}/messages/{id}", s.requireAuth(s.handleChatDelete))
|
||||
mux.HandleFunc("GET /api/users/{id}", s.requireAuth(s.handlePublicProfile))
|
||||
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)))
|
||||
@@ -89,6 +91,7 @@ func newRouter(s *Server) http.Handler {
|
||||
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("GET /api/admin/users/{id}", admin(s.handleAdminGetUser))
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatEditOwnMessage(t *testing.T) {
|
||||
e := setup(t)
|
||||
student, studentID := register(t, e, "Chat Editor", "chat-editor@test.dev")
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("me: %d %v", code, me)
|
||||
}
|
||||
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||
|
||||
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "سلام"})
|
||||
if code != 201 {
|
||||
t.Fatalf("send: %d %v", code, body)
|
||||
}
|
||||
msg := body["message"].(map[string]any)
|
||||
msgID := int64(msg["id"].(float64))
|
||||
|
||||
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": "سلام ویرایش"})
|
||||
if code != 200 {
|
||||
t.Fatalf("edit: %d %v", code, body)
|
||||
}
|
||||
edited := body["message"].(map[string]any)
|
||||
if edited["body"] != "سلام ویرایش" || edited["editedAt"] == nil {
|
||||
t.Fatalf("expected edited body, got %v", edited)
|
||||
}
|
||||
|
||||
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{"body": "نه"})
|
||||
if code != 404 {
|
||||
t.Fatalf("partner edit should be rejected: %d", code)
|
||||
}
|
||||
|
||||
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{"body": " "})
|
||||
if code != 400 {
|
||||
t.Fatalf("empty edit: %d", code)
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("list: %d %v", code, body)
|
||||
}
|
||||
msgs := body["messages"].([]any)
|
||||
last := msgs[len(msgs)-1].(map[string]any)
|
||||
if last["body"] != "سلام ویرایش" || last["editedAt"] == nil {
|
||||
t.Fatalf("listed message not edited: %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReplaceAttachment(t *testing.T) {
|
||||
e := setup(t)
|
||||
student, studentID := register(t, e, "Media Editor", "media-editor@test.dev")
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("me: %d %v", code, me)
|
||||
}
|
||||
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||
|
||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
|
||||
code, up := uploadChatFile(t, e, student, "one.png", png)
|
||||
if code != 201 {
|
||||
t.Fatalf("upload: %d %v", code, up)
|
||||
}
|
||||
first := up["attachment"].(map[string]any)
|
||||
|
||||
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
|
||||
"body": "",
|
||||
"attachment": first,
|
||||
})
|
||||
if code != 201 {
|
||||
t.Fatalf("send: %d %v", code, body)
|
||||
}
|
||||
msg := body["message"].(map[string]any)
|
||||
msgID := int64(msg["id"].(float64))
|
||||
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
|
||||
|
||||
png[8] = 0x01
|
||||
code, up = uploadChatFile(t, e, student, "two.png", png)
|
||||
if code != 201 {
|
||||
t.Fatalf("upload replacement: %d %v", code, up)
|
||||
}
|
||||
next := up["attachment"].(map[string]any)
|
||||
|
||||
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||
"body": "",
|
||||
"attachment": next,
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatalf("replace: %d %v", code, body)
|
||||
}
|
||||
edited := body["message"].(map[string]any)
|
||||
newPath := mediaPath(edited["attachment"].(map[string]any)["url"])
|
||||
if edited["editedAt"] == nil || newPath == "" || newPath == oldPath {
|
||||
t.Fatalf("expected a replaced attachment, old %s new %v", oldPath, edited)
|
||||
}
|
||||
if edited["attachment"].(map[string]any)["type"] != "image" {
|
||||
t.Fatalf("type: %v", edited["attachment"])
|
||||
}
|
||||
|
||||
wrong := map[string]any{"type": "video", "url": next["url"], "name": "clip.mp4", "size": next["size"]}
|
||||
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||
"body": "",
|
||||
"attachment": wrong,
|
||||
})
|
||||
if code != 404 {
|
||||
t.Fatalf("different kind should miss: %d", code)
|
||||
}
|
||||
|
||||
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
|
||||
"body": "",
|
||||
"attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1},
|
||||
})
|
||||
if code != 400 {
|
||||
t.Fatalf("file replace: %d", code)
|
||||
}
|
||||
|
||||
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(studentID)+"/messages/"+itoa(msgID), admin, map[string]any{
|
||||
"body": "",
|
||||
"attachment": next,
|
||||
})
|
||||
if code != 400 {
|
||||
t.Fatalf("partner replace: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReadMarksOnlyIncoming(t *testing.T) {
|
||||
e := setup(t)
|
||||
student, studentID := register(t, e, "Seen Student", "seen-student@test.dev")
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("me: %d %v", code, me)
|
||||
}
|
||||
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||
|
||||
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"body": "از هنرجو"})
|
||||
if code != 201 {
|
||||
t.Fatalf("student send: %d %v", code, body)
|
||||
}
|
||||
studentMsg := int64(body["message"].(map[string]any)["id"].(float64))
|
||||
|
||||
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("student list: %d %v", code, body)
|
||||
}
|
||||
if chatMsg(t, body, studentMsg)["readAt"] != nil {
|
||||
t.Fatalf("opening my own thread marked it seen: %v", chatMsg(t, body, studentMsg))
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("admin list: %d %v", code, body)
|
||||
}
|
||||
if chatMsg(t, body, studentMsg)["readAt"] == nil {
|
||||
t.Fatalf("partner open should mark the incoming message seen")
|
||||
}
|
||||
|
||||
code, body = e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", admin, map[string]any{"body": "از ادمین"})
|
||||
if code != 201 {
|
||||
t.Fatalf("admin send: %d %v", code, body)
|
||||
}
|
||||
adminMsg := int64(body["message"].(map[string]any)["id"].(float64))
|
||||
|
||||
code, body = e.do(t, "GET", "/api/chats/"+itoa(studentID)+"/messages", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("admin list again: %d %v", code, body)
|
||||
}
|
||||
if chatMsg(t, body, adminMsg)["readAt"] != nil {
|
||||
t.Fatalf("admin own message was marked seen: %v", chatMsg(t, body, adminMsg))
|
||||
}
|
||||
if chatMsg(t, body, studentMsg)["readAt"] == nil {
|
||||
t.Fatalf("already seen incoming message lost its receipt")
|
||||
}
|
||||
}
|
||||
|
||||
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
|
||||
t.Helper()
|
||||
for _, raw := range body["messages"].([]any) {
|
||||
msg := raw.(map[string]any)
|
||||
if int64(msg["id"].(float64)) == id {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatalf("message %d missing in %v", id, body["messages"])
|
||||
return nil
|
||||
}
|
||||
|
||||
func mediaPath(raw any) string {
|
||||
s, _ := raw.(string)
|
||||
if i := strings.IndexByte(s, '?'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestLearningDetailMatchesActiveStudents(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
|
||||
code, body := e.do(t, "GET", "/api/admin/stats", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("stats: %d %v", code, body)
|
||||
}
|
||||
if body["stats"].(map[string]any)["users"].(float64) != 0 {
|
||||
t.Fatalf("overview should ignore staff, got %v", body["stats"])
|
||||
}
|
||||
|
||||
_, uid := register(t, e, "Active Student", "active-student@test.dev")
|
||||
|
||||
code, body = e.do(t, "GET", "/api/admin/stats", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("stats after student: %d %v", code, body)
|
||||
}
|
||||
if body["stats"].(map[string]any)["users"].(float64) != 1 {
|
||||
t.Fatalf("expected 1 active student, got %v", body["stats"])
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/admin/learning/overview", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("overview: %d %v", code, body)
|
||||
}
|
||||
if body["overview"].(map[string]any)["studentsActive"].(float64) != 1 {
|
||||
t.Fatalf("learning active mismatch: %v", body["overview"])
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/admin/users/"+itoa(uid)+"/learning", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("learning detail: %d %v", code, body)
|
||||
}
|
||||
user := body["learning"].(map[string]any)["user"].(map[string]any)
|
||||
if user["name"] != "Active Student" {
|
||||
t.Fatalf("detail user: %v", user)
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/admin/users/999999/learning", admin, nil)
|
||||
if code != 404 {
|
||||
t.Fatalf("missing user: %d %v", code, body)
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/admin/learning/students?page=1&pageSize=10", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("students page: %d %v", code, body)
|
||||
}
|
||||
if body["total"].(float64) != 1 || len(body["items"].([]any)) != 1 {
|
||||
t.Fatalf("expected one page row, got %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteStatusFilter(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, body := e.do(t, "POST", "/api/admin/invites", admin, map[string]any{"maxUses": 2, "label": "مهر"})
|
||||
if code != 201 {
|
||||
t.Fatalf("create invite: %d %v", code, body)
|
||||
}
|
||||
code, body = e.do(t, "GET", "/api/admin/invites?status=active", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("active filter: %d %v", code, body)
|
||||
}
|
||||
if body["total"].(float64) < 1 {
|
||||
t.Fatalf("expected active invites, got %v", body)
|
||||
}
|
||||
code, body = e.do(t, "GET", "/api/admin/invites?status=revoked", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("revoked filter: %d %v", code, body)
|
||||
}
|
||||
if body["total"].(float64) != 0 {
|
||||
t.Fatalf("expected no revoked invites, got %v", body)
|
||||
}
|
||||
code, body = e.do(t, "GET", "/api/admin/invites?status=nope", admin, nil)
|
||||
if code != 400 {
|
||||
t.Fatalf("bad status: %d %v", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileOmitsPrivateFields(t *testing.T) {
|
||||
e := setup(t)
|
||||
student, studentID := register(t, e, "Public Name", "public-name@test.dev")
|
||||
admin := login(t, e, bootstrapAdminEmail)
|
||||
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("me: %d %v", code, me)
|
||||
}
|
||||
adminID := int64(me["user"].(map[string]any)["id"].(float64))
|
||||
|
||||
code, body := e.do(t, "GET", "/api/users/"+itoa(adminID), student, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("profile: %d %v", code, body)
|
||||
}
|
||||
user := body["user"].(map[string]any)
|
||||
if user["name"] == nil || user["name"] == "" {
|
||||
t.Fatalf("missing name: %v", user)
|
||||
}
|
||||
if _, ok := user["email"]; ok {
|
||||
t.Fatalf("email must not be public: %v", user)
|
||||
}
|
||||
if _, ok := user["phone"]; ok {
|
||||
t.Fatalf("phone must not be public: %v", user)
|
||||
}
|
||||
|
||||
code, adminView := e.do(t, "GET", "/api/admin/users/"+itoa(studentID), admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("admin user: %d %v", code, adminView)
|
||||
}
|
||||
managed := adminView["user"].(map[string]any)
|
||||
if managed["email"] != "public-name@test.dev" {
|
||||
t.Fatalf("admin should see email: %v", managed)
|
||||
}
|
||||
if _, ok := managed["passwordHash"]; ok {
|
||||
t.Fatalf("password hash must stay hidden: %v", managed)
|
||||
}
|
||||
code, hidden := e.do(t, "GET", "/api/admin/users/"+itoa(adminID), student, nil)
|
||||
if code != 403 {
|
||||
t.Fatalf("student must not open admin user: %d %v", code, hidden)
|
||||
}
|
||||
|
||||
code, body = e.do(t, "GET", "/api/users/"+itoa(studentID), admin, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("student profile: %d %v", code, body)
|
||||
}
|
||||
if body["user"].(map[string]any)["username"] != "public-name" {
|
||||
t.Fatalf("username: %v", body["user"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user