feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"pargar/backend/internal/store"
)
const msgUsernameCooldown = "سقف سه تغییر نام کاربری پر شده است. تا پایان مهلت نمی‌توانید دوباره نام کاربری را عوض کنید."
func (s *Server) handleUpdateUsername(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.Password) != "" {
writeErr(w, http.StatusBadRequest, "تغییر گذرواژه از این مسیر ممکن نیست")
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
if username == "" {
writeErr(w, http.StatusBadRequest, "شناسه کاربری الزامی است")
return
}
if !validUsername(username) {
writeErr(w, http.StatusBadRequest, msgUsernameInvalid)
return
}
user, err := s.store.ChangeUsername(r.Context(), me.ID, username, time.Now())
if err != nil {
var cool *store.UsernameCooldownError
if errors.As(err, &cool) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": msgUsernameCooldown,
"code": "username_cooldown",
"requestId": requestIDFromWriter(w),
"usernameCooldownUntil": cool.Until,
"usernameChangesRemaining": 0,
})
return
}
if errors.Is(err, store.ErrUsernameTaken) {
writeErr(w, http.StatusConflict, "این شناسه قبلاً گرفته شده است")
return
}
writeErr(w, http.StatusInternalServerError, "تغییر نام کاربری ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{
"user": user,
"usernameChangesRemaining": user.UsernameChangesRemaining,
"usernameCooldownUntil": user.UsernameCooldownUntil,
})
}
func (s *Server) handleUpdateAccount(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
CurrentPassword string `json:"currentPassword"`
Password string `json:"password"`
Phone *string `json:"phone"`
Name *string `json:"name"`
Email *string `json:"email"`
Role *string `json:"role"`
Username *string `json:"username"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if req.Role != nil && strings.TrimSpace(*req.Role) != me.Role.String() {
writeErr(w, http.StatusForbidden, "نقش از این مسیر تغییر نمی‌کند")
return
}
if req.Username != nil {
next := strings.ToLower(strings.TrimSpace(*req.Username))
if next != "" && next != strings.ToLower(me.Username) {
writeErr(w, http.StatusBadRequest, "نام کاربری از این مسیر تغییر نمی‌کند")
return
}
}
hash := ""
changingPassword := strings.TrimSpace(req.Password) != ""
if changingPassword {
if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) {
writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است")
return
}
if utf8.RuneCountInString(req.Password) < minPasswordLen {
writeErr(w, http.StatusBadRequest, msgPasswordShort)
return
}
if len(req.Password) > maxPasswordLen {
writeErr(w, http.StatusBadRequest, msgPasswordLong)
return
}
var err error
hash, err = s.auth.HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "تغییر گذرواژه ممکن نشد")
return
}
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if msg := validatePersonName(name); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
}
email := ""
if req.Email != nil {
email = strings.ToLower(strings.TrimSpace(*req.Email))
if !validEmail(email) {
writeErr(w, http.StatusBadRequest, msgEmailInvalid)
return
}
if !strings.EqualFold(email, me.Email) {
if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil {
writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است")
return
} else if !errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
}
}
phone := me.Phone
if req.Phone != nil {
phone = normalizePhone(*req.Phone)
if !validPhone(phone) {
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
return
}
if phone != "" && phone != me.Phone {
if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil {
writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است")
return
} else if !errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
}
}
user, err := s.store.UpdateUser(r.Context(), me.ID, name, email, "", hash, phone, "", "", me.Role, me.Hearts, me.XP)
if err != nil {
if store.IsUniqueViolation(err) {
writeErr(w, http.StatusConflict, "این مقدار قبلاً ثبت شده است")
return
}
writeErr(w, http.StatusInternalServerError, "ذخیره حساب ممکن نشد")
return
}
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
const (
msgAccountClosedLogin = "حساب بسته شده است و ورود ممکن نیست"
msgFreezeAlready = "حساب همین حالا فریز است"
msgAccountClosed = "حساب بسته شده است"
msgUnfreezeClosed = "مهلت فریز تمام شده و لغو فریز ممکن نیست"
msgUnfreezeNone = "حساب فریز نیست"
msgDeleteNotFrozen = "حذف دائمی فقط برای حساب فریز یا بسته ممکن است"
)
// settleAccount writes closed_at when a freeze has passed the 30-day window.
func (s *Server) settleAccount(ctx context.Context, u *models.User) *models.User {
if u == nil || u.ClosedAt != nil || u.FrozenAt == nil || !u.IsClosed {
return u
}
next, err := s.store.MarkAccountClosed(ctx, u.ID)
if err != nil || next == nil {
return u
}
return next
}
func (s *Server) handleFreezeAccount(w http.ResponseWriter, r *http.Request) {
me := s.settleAccount(r.Context(), currentUser(r))
var req struct {
CurrentPassword string `json:"currentPassword"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.CurrentPassword) == "" {
writeErr(w, http.StatusBadRequest, "گذرواژه فعلی الزامی است")
return
}
if !s.auth.CheckPassword(me.PasswordHash, req.CurrentPassword) {
writeErr(w, http.StatusForbidden, "گذرواژه فعلی نادرست است")
return
}
if me.IsClosed {
writeErr(w, http.StatusConflict, msgAccountClosed)
return
}
if me.IsFrozen {
writeErr(w, http.StatusConflict, msgFreezeAlready)
return
}
user, err := s.store.FreezeUser(r.Context(), me.ID)
if err != nil {
if errors.Is(err, store.ErrAccountClosed) {
writeErr(w, http.StatusConflict, msgAccountClosed)
return
}
if errors.Is(err, store.ErrAccountFrozen) {
writeErr(w, http.StatusConflict, msgFreezeAlready)
return
}
writeErr(w, http.StatusInternalServerError, "فریز حساب ممکن نشد")
return
}
observability.Activity("account.frozen", "user_id", user.ID)
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
func (s *Server) handleUnfreezeAccount(w http.ResponseWriter, r *http.Request) {
me := s.settleAccount(r.Context(), currentUser(r))
if me.IsClosed {
writeErr(w, http.StatusConflict, msgUnfreezeClosed)
return
}
if !me.IsFrozen {
writeErr(w, http.StatusConflict, msgUnfreezeNone)
return
}
user, err := s.store.UnfreezeUser(r.Context(), me.ID)
if err != nil {
if errors.Is(err, store.ErrAccountClosed) {
_, _ = s.store.MarkAccountClosed(r.Context(), me.ID)
writeErr(w, http.StatusConflict, msgUnfreezeClosed)
return
}
if errors.Is(err, store.ErrAccountNotFrozen) {
writeErr(w, http.StatusConflict, msgUnfreezeNone)
return
}
writeErr(w, http.StatusInternalServerError, "لغو فریز ممکن نشد")
return
}
observability.Activity("account.unfrozen", "user_id", user.ID)
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+102
View File
@@ -0,0 +1,102 @@
package api_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
)
func TestAccountFreeze(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Freeze", "freeze@test.dev")
code, missing := e.do(t, "POST", "/api/me/freeze", token, map[string]any{})
missingMsg, _ := missing["error"].(string)
if code == http.StatusOK || !strings.Contains(missingMsg, "گذرواژه") {
t.Fatalf("freeze without password: %d %q", code, missingMsg)
}
code, wrong := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "not-the-password"})
wrongMsg, _ := wrong["error"].(string)
if code != http.StatusForbidden || !strings.Contains(wrongMsg, "گذرواژه") || !strings.Contains(wrongMsg, "نادرست") {
t.Fatalf("freeze with wrong password: %d %q", code, wrongMsg)
}
var untouched *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&untouched); err != nil {
t.Fatal(err)
}
if untouched != nil {
t.Fatal("failed freeze wrote frozen_at")
}
code, body := e.do(t, "POST", "/api/me/freeze", token, map[string]any{"currentPassword": "password123"})
if code != http.StatusOK {
t.Fatalf("freeze: %d %v", code, body["error"])
}
user := body["user"].(map[string]any)
frozenAt, _ := user["frozenAt"].(string)
if frozenAt == "" || user["isFrozen"] != true || user["isClosed"] == true {
t.Fatalf("freeze state: frozenAt=%v isFrozen=%v isClosed=%v", user["frozenAt"], user["isFrozen"], user["isClosed"])
}
if user["isLocked"] == true {
t.Fatal("freeze reused the admin lock flag")
}
var stored *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == nil || stored.IsZero() {
t.Fatal("frozen_at was not stored")
}
code, profile := e.do(t, "GET", "/api/users/"+itoa(uid), token, nil)
if code != http.StatusOK {
t.Fatalf("profile: %d %v", code, profile["error"])
}
pub := profile["user"].(map[string]any)
if pub["isFrozen"] != true {
t.Fatalf("public profile missing freeze flag: %v", pub["isFrozen"])
}
if _, ok := pub["email"]; ok {
t.Fatalf("public profile leaked email: %v", pub["email"])
}
_ = login(t, e, "freeze@test.dev")
code, body = e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{})
if code != http.StatusOK {
t.Fatalf("unfreeze: %d %v", code, body["error"])
}
cleared := body["user"].(map[string]any)
if cleared["isFrozen"] == true || cleared["frozenAt"] != nil {
t.Fatalf("unfreeze left a timestamp: %v", cleared["frozenAt"])
}
var after *time.Time
if err := e.pool.QueryRow(context.Background(), `SELECT frozen_at FROM users WHERE id=$1`, uid).Scan(&after); err != nil {
t.Fatal(err)
}
if after != nil {
t.Fatalf("frozen_at still set: %s", after.Format(time.RFC3339))
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET closed_at = now() WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, denied := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "freeze@test.dev", "password123"))
errMsg, _ := denied["error"].(string)
if code != http.StatusForbidden || !strings.Contains(errMsg, "بسته") || !strings.Contains(errMsg, "ورود") {
t.Fatalf("closed login: %d %q", code, errMsg)
}
code, undo := e.do(t, "POST", "/api/me/unfreeze", token, map[string]any{})
undoMsg, _ := undo["error"].(string)
if code != http.StatusConflict || !strings.Contains(undoMsg, "لغو فریز") {
t.Fatalf("unfreeze after close: %d %q", code, undoMsg)
}
code, forbidden := e.do(t, "DELETE", "/api/admin/users/"+itoa(uid), token, nil)
if code != http.StatusForbidden {
t.Fatalf("student permanent delete: %d %v", code, forbidden["error"])
}
}
@@ -0,0 +1,186 @@
package api_test
import (
"context"
"net/http"
"strings"
"testing"
)
func TestAccountUsernameQuota(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Quota", "quota@test.dev")
code, body := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quota"})
if code != http.StatusOK {
t.Fatalf("same username: %d %v", code, body["error"])
}
if body["usernameChangesRemaining"].(float64) != 3 {
t.Fatalf("same username consumed a slot: %v", body["usernameChangesRemaining"])
}
for i, next := range []string{"quotaone", "quotatwo", "quotathree"} {
code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": next})
if code != http.StatusOK {
t.Fatalf("change %d: %d %v", i+1, code, body["error"])
}
user := body["user"].(map[string]any)
if user["username"] != next {
t.Fatalf("username: %v", user["username"])
}
wantRemaining := float64(2 - i)
if body["usernameChangesRemaining"].(float64) != wantRemaining {
t.Fatalf("remaining after change %d: %v", i+1, body["usernameChangesRemaining"])
}
}
if body["usernameCooldownUntil"] == nil || body["usernameCooldownUntil"] == "" {
t.Fatalf("third change should start cooldown: %v", body["usernameCooldownUntil"])
}
code, blocked := e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"})
errMsg, _ := blocked["error"].(string)
if code != http.StatusTooManyRequests || !strings.Contains(errMsg, "نام کاربری") || !strings.Contains(errMsg, "مهلت") {
t.Fatalf("fourth change: %d %q", code, errMsg)
}
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
if me["user"].(map[string]any)["username"] != "quotathree" {
t.Fatalf("username changed during cooldown: %v", me["user"].(map[string]any)["username"])
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET username_cooldown_until = now() - interval '1 second' WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, body = e.do(t, "PUT", "/api/me/username", token, map[string]any{"username": "quotafour"})
if code != http.StatusOK {
t.Fatalf("after cooldown: %d %v", code, body["error"])
}
if body["user"].(map[string]any)["username"] != "quotafour" {
t.Fatalf("username after cooldown: %v", body["user"])
}
if body["usernameChangesRemaining"].(float64) != 2 {
t.Fatalf("allowance should reset then consume one: %v", body["usernameChangesRemaining"])
}
code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"})
if code != http.StatusForbidden {
t.Fatalf("password change without current password: %d %v", code, denied["error"])
}
_ = login(t, e, "quota@test.dev")
code, rejected := e.do(t, "PUT", "/api/me/username", token, map[string]any{
"username": "quotafive",
"password": "should-be-ignored",
})
if code != http.StatusBadRequest {
t.Fatalf("username endpoint accepted a password: %d %v", code, rejected["error"])
}
code, me = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK || me["user"].(map[string]any)["username"] != "quotafour" {
t.Fatalf("username changed via password field: %d %v", code, me["user"])
}
}
func TestAccountIdentityWithoutPassword(t *testing.T) {
e := setup(t)
token, _ := register(t, e, "Identity", "identity@test.dev")
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
before := me["user"].(map[string]any)
username, _ := before["username"].(string)
role, _ := before["role"].(string)
code, body := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"name": "هویت تازه",
"email": "identity-new@test.dev",
"phone": "09120001122",
})
if code != http.StatusOK {
t.Fatalf("identity save: %d %v", code, body["error"])
}
user := body["user"].(map[string]any)
if user["name"] != "هویت تازه" || user["email"] != "identity-new@test.dev" || user["phone"] != "09120001122" {
t.Fatalf("saved identity: name=%v email=%v phone=%v", user["name"], user["email"], user["phone"])
}
if user["username"] != username || user["role"] != role {
t.Fatalf("username or role changed: username=%v role=%v", user["username"], user["role"])
}
if user["securityQuestion"] != before["securityQuestion"] {
t.Fatalf("security question changed: %v", user["securityQuestion"])
}
_ = login(t, e, "identity-new@test.dev")
code, same := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"name": "هویت تازه",
"email": "identity-new@test.dev",
"phone": "09120001122",
})
if code != http.StatusOK {
t.Fatalf("same identity: %d %v", code, same["error"])
}
_, _ = register(t, e, "Other", "taken-mail@test.dev")
code, dup := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "taken-mail@test.dev"})
if code != http.StatusConflict {
t.Fatalf("duplicate email: %d %v", code, dup["error"])
}
code, bad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"email": "not-an-email"})
if code != http.StatusBadRequest {
t.Fatalf("invalid email: %d %v", code, bad["error"])
}
code, phoneBad := e.do(t, "PUT", "/api/me/account", token, map[string]any{"phone": "123"})
if code != http.StatusBadRequest {
t.Fatalf("invalid phone: %d %v", code, phoneBad["error"])
}
code, roleBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"role": "admin", "name": "نباید ذخیره شود"})
if code != http.StatusForbidden {
t.Fatalf("role change: %d %v", code, roleBody["error"])
}
code, userBody := e.do(t, "PUT", "/api/me/account", token, map[string]any{"username": "hijackname"})
if code != http.StatusBadRequest {
t.Fatalf("username via account: %d %v", code, userBody["error"])
}
code, me = e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me after rejects: %d", code)
}
after := me["user"].(map[string]any)
if after["role"] != role || after["username"] != username || after["name"] != "هویت تازه" || after["email"] != "identity-new@test.dev" {
t.Fatalf("account mutated by rejected fields: %v", after)
}
code, denied := e.do(t, "PUT", "/api/me/account", token, map[string]any{"password": "replacement-pass"})
if code != http.StatusForbidden {
t.Fatalf("password without current: %d %v", code, denied["error"])
}
_ = login(t, e, "identity-new@test.dev")
code, changed := e.do(t, "PUT", "/api/me/account", token, map[string]any{
"currentPassword": "password123",
"password": "replacement-pass",
})
if code != http.StatusOK {
t.Fatalf("password with current: %d %v", code, changed["error"])
}
kept := changed["user"].(map[string]any)
if kept["name"] != "هویت تازه" || kept["email"] != "identity-new@test.dev" || kept["phone"] != "09120001122" || kept["role"] != role {
t.Fatalf("password save changed identity: %v", kept)
}
code, session := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "replacement-pass"))
if code != http.StatusOK || session["accessToken"] == nil {
t.Fatalf("login after password change: %d %v", code, session["error"])
}
code, old := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "identity-new@test.dev", "password123"))
if code == http.StatusOK {
t.Fatalf("previous password still works: %d", code)
}
_ = old["error"]
}
+11 -55
View File
@@ -33,6 +33,7 @@ func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
u = s.settleAccount(r.Context(), u)
writeJSON(w, http.StatusOK, map[string]any{"user": u})
}
@@ -442,6 +443,16 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
return
}
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
target = s.settleAccount(r.Context(), target)
if !target.IsFrozen && !target.IsClosed {
writeErr(w, http.StatusConflict, msgDeleteNotFrozen)
return
}
if err := s.store.DeleteUser(r.Context(), id); err != nil {
writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد")
return
@@ -837,58 +848,3 @@ func validateAdminEvent(e *models.Event, create bool) string {
_ = 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)})
}
+13
View File
@@ -175,6 +175,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, "حساب غیرفعال است")
return
}
user = s.settleAccount(r.Context(), user)
if user.IsClosed {
observability.Logins.WithLabelValues("closed").Inc()
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
return
}
refreshTTL := s.auth.refreshTTL(req.RememberMe)
access, refresh, err := s.auth.IssueForUserTTL(r.Context(), user, refreshTTL)
if err != nil {
@@ -222,6 +228,12 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "حساب غیرفعال است")
return
}
user = s.settleAccount(r.Context(), user)
if user.IsClosed {
s.clearSessionCookies(w)
writeErr(w, http.StatusForbidden, msgAccountClosedLogin)
return
}
// Preserve remember-me vs short session when rotating the refresh token.
ttl := s.cfg.RefreshTokenTTLSession
if lifetime >= 48*time.Hour {
@@ -266,6 +278,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if err != nil {
user = hearts
}
user = s.settleAccount(r.Context(), user)
s.signUserMedia(user)
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+287 -90
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -18,38 +19,59 @@ 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())
page := parsePageParams(r)
if page.PageSize < 20 {
page.PageSize = 20
page.Offset = (page.Page - 1) * page.PageSize
}
roles := []string{models.RoleMentor.String(), models.RoleAdmin.String()}
if actor.Role == models.RoleMentor || actor.Role == models.RoleAdmin {
roles = []string{models.RoleStudent.String(), models.RoleMentor.String(), models.RoleAdmin.String()}
}
people, total, err := s.store.ListActiveByRoles(r.Context(), roles, page.PageSize, page.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری منتورها ممکن نشد")
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
ids := make([]int64, 0, len(people))
for _, m := range people {
if m.ID != actor.ID {
ids = append(ids, m.ID)
}
if actor.Role == models.RoleStudent && m.Role != models.RoleMentor && m.Role != models.RoleAdmin {
}
seen, _ := s.store.LastSeenByIDs(r.Context(), ids)
safe := make([]map[string]any, 0, len(people))
for _, m := range people {
if m.ID == actor.ID {
continue
}
s.signUserMedia(&m)
safe = append(safe, map[string]any{
item := map[string]any{
"id": m.ID, "name": m.Name, "email": m.Email, "role": m.Role,
"avatarVariant": m.AvatarVariant, "avatarPalette": m.AvatarPalette, "avatarPhoto": m.AvatarPhoto,
})
"online": s.hub.Online(m.ID),
}
if ts, ok := seen[m.ID]; ok && !ts.IsZero() {
item["lastSeen"] = ts.UTC()
}
safe = append(safe, item)
}
writeJSON(w, http.StatusOK, map[string]any{"mentors": safe})
writeJSON(w, http.StatusOK, map[string]any{
"mentors": safe,
"total": total,
"page": page.Page,
"pageSize": page.PageSize,
})
}
type conversation struct {
Partner map[string]any `json:"partner"`
LastMessage *models.ChatMessage `json:"lastMessage,omitempty"`
UnreadCount int `json:"unreadCount"`
Partner map[string]any `json:"partner"`
LastMessage *models.ChatMessage `json:"lastMessage,omitempty"`
UnreadCount int `json:"unreadCount"`
MessageCount int `json:"messageCount"`
PinnedRank *int `json:"pinnedRank"`
Muted bool `json:"muted"`
Archived bool `json:"archived"`
}
func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
@@ -94,11 +116,23 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
return
}
showArchived := r.URL.Query().Get("archived") == "1"
partnerIDs := make([]int64, 0, len(partners))
for _, p := range partners {
if p.ID != u.ID {
partnerIDs = append(partnerIDs, p.ID)
}
}
seen, _ := s.store.LastSeenByIDs(r.Context(), partnerIDs)
out := make([]conversation, 0, len(partners))
for _, p := range partners {
if !s.canChatWith(r.Context(), u, p.ID) {
continue
}
pref := s.store.ChatPref(r.Context(), u.ID, p.ID)
if pref.Archived != showArchived {
continue
}
msgs, err := s.store.ListChatMessages(r.Context(), u.ID, p.ID, 0, 100)
if err != nil {
continue
@@ -118,23 +152,95 @@ func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
s.signUserMedia(tmp)
photo = tmp.AvatarPhoto
}
name := p.Name
saved := p.ID == u.ID
if saved {
name = "پیام‌های ذخیره‌شده"
}
partner := map[string]any{
"id": p.ID, "name": name, "email": p.Email, "role": p.Role,
"avatarVariant": p.AvatarVariant, "avatarPalette": p.AvatarPalette, "avatarPhoto": photo,
"online": !saved && s.hub.Online(p.ID), "saved": saved,
}
if !saved {
if ts, ok := seen[p.ID]; ok && !ts.IsZero() {
partner["lastSeen"] = ts.UTC()
}
}
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": photo,
"online": s.hub.Online(p.ID),
},
UnreadCount: unread,
Partner: partner,
UnreadCount: unread,
MessageCount: len(msgs),
PinnedRank: pref.PinnedRank,
Muted: pref.Muted,
Archived: pref.Archived,
}
if last != nil {
conv.LastMessage = last
}
out = append(out, conv)
}
if !showArchived {
hasSelf := false
for _, c := range out {
if id, _ := c.Partner["id"].(int64); id == u.ID {
hasSelf = true
break
}
}
if !hasSelf {
out = append(out, conversation{
Partner: map[string]any{
"id": u.ID, "name": "پیام‌های ذخیره‌شده", "email": u.Email, "role": u.Role,
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"online": false, "saved": true,
},
})
}
}
if out == nil {
out = []conversation{}
}
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
sort.SliceStable(out, func(i, j int) bool {
pi, pj := out[i].PinnedRank, out[j].PinnedRank
if (pi != nil) != (pj != nil) {
return pi != nil
}
if pi != nil && pj != nil && *pi != *pj {
return *pi < *pj
}
ti := time.Time{}
tj := time.Time{}
if out[i].LastMessage != nil {
ti = out[i].LastMessage.CreatedAt
}
if out[j].LastMessage != nil {
tj = out[j].LastMessage.CreatedAt
}
return ti.After(tj)
})
total := len(out)
slice := out
page := pageParams{Page: 1, PageSize: total}
if r.URL.Query().Get("page") != "" || r.URL.Query().Get("pageSize") != "" {
page = parsePageParams(r)
start := page.Offset
if start > total {
start = total
}
end := start + page.PageSize
if end > total {
end = total
}
slice = out[start:end]
}
writeJSON(w, http.StatusOK, map[string]any{
"conversations": slice,
"total": total,
"page": page.Page,
"pageSize": page.PageSize,
"mutedAll": s.store.ChatMutedAll(r.Context(), u.ID),
})
}
func (s *Server) handleChatMessages(w http.ResponseWriter, r *http.Request) {
@@ -224,43 +330,22 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Body string `json:"body"`
ReplyTo *int64 `json:"replyTo"`
Attachment *models.Attachment `json:"attachment,omitempty"`
Body string `json:"body"`
ReplyTo *int64 `json:"replyTo"`
Attachment *models.Attachment `json:"attachment,omitempty"`
Attachments []models.Attachment `json:"attachments,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"
}
atts, attErr := prepareChatAttachments(u.ID, req.Attachment, req.Attachments)
if attErr != "" {
writeErr(w, http.StatusBadRequest, attErr)
return
}
if req.Body == "" && req.Attachment == nil {
if req.Body == "" && len(atts) == 0 {
writeErr(w, http.StatusBadRequest, "پیام باید شامل متن یا فایل باشد")
return
}
@@ -281,7 +366,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
mentorID = u.ID
}
msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, req.Attachment)
msg, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, u.Role, req.Body, req.ReplyTo, atts)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ارسال پیام ممکن نشد")
return
@@ -291,6 +376,7 @@ func (s *Server) handleChatSend(w http.ResponseWriter, r *http.Request) {
// 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})
s.noteBotReply(r, partnerID, req.Body)
// notify the recipient
if u.Role == models.RoleMentor || u.Role == models.RoleAdmin {
@@ -352,8 +438,14 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
}
msgID := routeID(r, "id")
var req struct {
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
Body string `json:"body"`
Attachment *models.Attachment `json:"attachment,omitempty"`
AddAttachments []models.Attachment `json:"addAttachments,omitempty"`
RemoveIndexes []int `json:"removeIndexes,omitempty"`
Replacements []struct {
Index int `json:"index"`
Attachment models.Attachment `json:"attachment"`
} `json:"replacements,omitempty"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
@@ -365,36 +457,73 @@ func (s *Server) handleChatEdit(w http.ResponseWriter, r *http.Request) {
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)
current, err := s.store.GetOwnChatMessage(r.Context(), u.ID, partner, msgID, u.Role)
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "ویرایش پیام ممکن نشد")
return
}
existing := storedAttachments(current)
drop := map[int]struct{}{}
for _, index := range req.RemoveIndexes {
if index < 0 || index >= len(existing) {
writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد")
return
}
drop[index] = struct{}{}
}
for _, rep := range req.Replacements {
if _, removed := drop[rep.Index]; removed {
continue
}
if rep.Index < 0 || rep.Index >= len(existing) {
writeErr(w, http.StatusBadRequest, "پیوست انتخاب‌شده پیدا نشد")
return
}
prepared, attErr := prepareChatAttachments(u.ID, &rep.Attachment, nil)
if attErr != "" {
writeErr(w, http.StatusBadRequest, attErr)
return
}
if prepared[0].Type != existing[rep.Index].Type {
writeErr(w, http.StatusBadRequest, "فایل جدید باید از همان نوع باشد")
return
}
existing[rep.Index] = prepared[0]
}
toAdd := req.AddAttachments
if req.Attachment != nil {
toAdd = append(toAdd, *req.Attachment)
}
added, attErr := prepareChatAttachments(u.ID, nil, toAdd)
if attErr != "" {
writeErr(w, http.StatusBadRequest, attErr)
return
}
final := make([]models.Attachment, 0, len(existing)+len(added))
for i, att := range existing {
if _, removed := drop[i]; removed {
continue
}
final = append(final, att)
}
final = append(final, added...)
if len(final) > maxChatAttachments {
writeErr(w, http.StatusBadRequest, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد")
return
}
if req.Body == "" && len(final) == 0 {
writeErr(w, http.StatusBadRequest, "پیام باید متن یا پیوست داشته باشد")
return
}
msg, err := s.store.SaveEditedChatMessage(r.Context(), u.ID, partner, msgID, u.Role, req.Body, final)
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "پیام پیدا نشد")
return
@@ -566,9 +695,12 @@ func (s *Server) markIncomingChatRead(ctx context.Context, reader *models.User,
}
func (s *Server) canChatWith(ctx context.Context, actor *models.User, partnerID int64) bool {
if actor == nil || partnerID <= 0 || actor.ID == partnerID {
if actor == nil || partnerID <= 0 {
return false
}
if actor.ID == partnerID {
return true
}
partner, err := s.store.GetUserByID(ctx, partnerID)
if err != nil || !partner.IsActive {
return false
@@ -586,10 +718,75 @@ func (s *Server) resignChatMessages(msgs []models.ChatMessage) {
}
func (s *Server) resignChatMessage(msg *models.ChatMessage) {
if msg == nil || msg.Attachment == nil || msg.Attachment.URL == "" {
if msg == nil {
return
}
msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL)
for i := range msg.Attachments {
if msg.Attachments[i].URL != "" {
msg.Attachments[i].URL = s.signExistingMediaURL(msg.Attachments[i].URL)
}
}
if len(msg.Attachments) > 0 {
first := msg.Attachments[0]
msg.Attachment = &first
return
}
if msg.Attachment != nil && msg.Attachment.URL != "" {
msg.Attachment.URL = s.signExistingMediaURL(msg.Attachment.URL)
}
}
func storedAttachments(m *models.ChatMessage) []models.Attachment {
if m == nil {
return nil
}
if len(m.Attachments) > 0 {
out := make([]models.Attachment, len(m.Attachments))
copy(out, m.Attachments)
return out
}
if m.Attachment != nil && m.Attachment.URL != "" {
return []models.Attachment{*m.Attachment}
}
return nil
}
const maxChatAttachments = 10
func prepareChatAttachments(ownerID int64, single *models.Attachment, many []models.Attachment) ([]models.Attachment, string) {
atts := many
if len(atts) == 0 && single != nil {
atts = []models.Attachment{*single}
}
if len(atts) > maxChatAttachments {
return nil, "در هر پیام حداکثر ۱۰ پیوست می‌توان فرستاد"
}
out := make([]models.Attachment, 0, len(atts))
for i := range atts {
att := atts[i]
if att.Size > maxUploadSize {
return nil, "حجم پیوست بیش از حد مجاز است"
}
att.Type = strings.ToLower(strings.TrimSpace(att.Type))
if att.Type != "image" && att.Type != "video" && att.Type != "audio" && att.Type != "file" {
return nil, "نوع پیوست پشتیبانی نمی‌شود"
}
if att.URL == "" || !strings.Contains(att.URL, "/media/") {
return nil, "نشانی پیوست نامعتبر است"
}
key := mediaKeyFromURL(att.URL)
if !mediaKeyOwnedByUser(key, ownerID) {
return nil, "پیوست متعلق به شما نیست"
}
if q := strings.Index(att.URL, "?"); q >= 0 {
att.URL = att.URL[:q]
}
if strings.TrimSpace(att.Name) == "" {
att.Name = "file"
}
out = append(out, att)
}
return out, ""
}
func itoa(v int64) string {
+46 -24
View File
@@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"pargar/backend/internal/models"
@@ -49,16 +50,17 @@ type chatExportAttachment struct {
}
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"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"`
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"`
EditedAt *time.Time `json:"editedAt,omitempty"`
Attachment *chatExportAttachment `json:"attachment,omitempty"`
Attachments []chatExportAttachment `json:"attachments,omitempty"`
}
type chatExportConversation struct {
@@ -186,20 +188,25 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
PinnedAt: m.PinnedAt,
EditedAt: m.EditedAt,
}
if m.Attachment != nil {
att := &chatExportAttachment{
Type: m.Attachment.Type,
Name: m.Attachment.Name,
Size: m.Attachment.Size,
}
key := mediaKeyFromURL(m.Attachment.URL)
list := m.Attachments
if len(list) == 0 && m.Attachment != nil {
list = []models.Attachment{*m.Attachment}
}
exported := make([]chatExportAttachment, 0, len(list))
for i, src := range list {
att := chatExportAttachment{Type: src.Type, Name: src.Name, Size: src.Size}
key := mediaKeyFromURL(src.URL)
if key == "" {
att.Missing = true
} else {
label := safeExportFilename(src.Name)
if len(list) > 1 {
label = fmt.Sprintf("%d-%s", i+1, label)
}
rel := filepath.ToSlash(filepath.Join(
"attachments",
convKey,
fmt.Sprintf("%d-%s", m.ID, safeExportFilename(m.Attachment.Name)),
fmt.Sprintf("%d-%s", m.ID, label),
))
rc, _, _, err := s.storage.Open(key)
if err != nil {
@@ -210,7 +217,14 @@ func (s *Server) buildProjectChatExport(r *http.Request) (*chatExportPayload, []
files = append(files, zipAttachmentFile{Path: rel, Key: key})
}
}
item.Attachment = att
exported = append(exported, att)
}
if len(exported) > 0 {
first := exported[0]
item.Attachment = &first
if len(exported) > 1 {
item.Attachments = exported
}
}
batch = append(batch, item)
}
@@ -373,11 +387,17 @@ func (s *Server) handleAdminChatExportRestore(w http.ResponseWriter, r *http.Req
if created.IsZero() {
created = time.Now().UTC()
}
var att *models.Attachment
if m.Attachment != nil {
att = s.materializeRestoredAttachment(studentID, m.Attachment, zipFiles)
srcs := m.Attachments
if len(srcs) == 0 && m.Attachment != nil {
srcs = []chatExportAttachment{*m.Attachment}
}
if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, att, created, m.Pinned); err != nil {
atts := make([]models.Attachment, 0, len(srcs))
for i := range srcs {
if got := s.materializeRestoredAttachment(studentID, &srcs[i], zipFiles); got != nil {
atts = append(atts, *got)
}
}
if _, err := s.store.ImportChatMessage(r.Context(), studentID, mentorID, role, m.Body, atts, created, m.Pinned); err != nil {
continue
}
imported++
@@ -459,6 +479,8 @@ type bytesFile struct {
func (b bytesFile) Close() error { return nil }
var restoreMediaSeq atomic.Uint64
func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAttachment, zipFiles map[string][]byte) *models.Attachment {
if src == nil {
return nil
@@ -476,7 +498,7 @@ func (s *Server) materializeRestoredAttachment(ownerID int64, src *chatExportAtt
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))
key := fmt.Sprintf("chats/%d-%d-%d%s", ownerID, time.Now().UnixNano(), restoreMediaSeq.Add(1), filepath.Ext(name))
url, err := s.storage.Save(key, bytesFile{Reader: bytes.NewReader(raw)}, int64(len(raw)))
if err == nil {
return &models.Attachment{
+1 -55
View File
@@ -1,65 +1,11 @@
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": "سلام؛ دربارهٔ حمایت مالی / دونیت هماهنگ کنیم.",
},
"socials": s.loadSocials(r.Context()),
})
}
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
}
+6 -9
View File
@@ -11,18 +11,15 @@ func TestCommunityEndpointPublic(t *testing.T) {
if code != http.StatusOK {
t.Fatalf("community: %d %v", code, body)
}
don, ok := body["donation"].(map[string]any)
socials, ok := body["socials"].([]any)
if !ok {
t.Fatalf("missing donation: %v", body)
t.Fatalf("missing socials: %v", body)
}
if _, ok := don["enabled"]; !ok {
t.Fatalf("missing donation.enabled: %v", don)
if socials == nil {
t.Fatal("socials must be a list")
}
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")
if _, hasDonation := body["donation"]; hasDonation {
t.Fatal("donation must not be exposed")
}
// Telegram channel surface was removed from the product.
if _, hasChannel := body["channel"]; hasChannel {
+41 -26
View File
@@ -3,6 +3,7 @@ package api_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
@@ -189,26 +190,39 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) {
token, uid := register(t, e, "Struggle", "struggle@test.dev")
watchToUnlock(t, e, token, 1)
adminEarly := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), adminEarly, map[string]any{"hearts": 3})
if code != http.StatusOK {
t.Fatalf("set hearts: %d", code)
}
// 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}})
// all-wrong answers spend the starting hearts. Zero does not lock by default.
var result map[string]any
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")
if result["locked"] == true {
t.Fatalf("empty hearts must not lock by default")
}
// 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)
if code == http.StatusLocked {
t.Fatalf("heartbeat should stay open while waiting for a heart")
}
code, _ = e.do(t, "POST", "/api/lessons/1/quiz/submit", token, map[string]any{"answers": []int{0, 0, 0}})
if code != http.StatusForbidden {
t.Fatalf("expected quiz blocked at 0 hearts, got %d", code)
}
// locked users may request staff review (does not unlock); only staff unlocks
// A manual lock still accepts a review request. Empty hearts alone do not.
code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/lock", adminEarly, nil)
if code != http.StatusOK {
t.Fatalf("manual lock: %d", code)
}
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)
@@ -248,8 +262,8 @@ func TestQuizFailLocksAccountAndMentorUnlocks(t *testing.T) {
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"])
if u["hearts"].(float64) != 5 {
t.Fatalf("hearts should be restored to 5, got %v", u["hearts"])
}
}
@@ -264,12 +278,12 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) {
}
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})
code, body = e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 9})
if code != http.StatusOK {
t.Fatalf("update user: %d", code)
}
user := body["user"].(map[string]any)
if user["hearts"].(float64) != 3 || user["isLocked"] != true {
if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("overflow must cap hearts and lock account: %v", user)
}
@@ -278,7 +292,7 @@ func TestAdminHeartOverflowLocksAndCapsAccount(t *testing.T) {
t.Fatalf("me: %d", code)
}
user = body["user"].(map[string]any)
if user["hearts"].(float64) != 3 || user["isLocked"] != true {
if user["hearts"].(float64) != 5 || user["isLocked"] != true {
t.Fatalf("persisted overflow guard failed: %v", user)
}
}
@@ -382,11 +396,13 @@ func TestChatRecipientPermissions(t *testing.T) {
}
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(studentID)+"/messages", studentToken, map[string]any{"body": "saved"})
if code != http.StatusCreated {
t.Fatalf("saved messages: %d", code)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(otherStudentID)+"/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 {
@@ -566,17 +582,16 @@ func TestAdminSettingsAndSponsors(t *testing.T) {
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":"حمایت"}]`,
"social_links": `[{"name":"تلگرام","url":"https://t.me/pargar"}]`,
"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"])
if !strings.Contains(settings["social_links"].(string), "تلگرام") {
t.Fatalf("social links not saved: %v", settings["social_links"])
}
code, body = e.do(t, "GET", "/api/sponsors", "", nil)
@@ -595,8 +610,8 @@ func TestAdminSettingsAndSponsors(t *testing.T) {
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"])
socials, _ := body["socials"].([]any)
if len(socials) != 1 || socials[0].(map[string]any)["name"] != "تلگرام" {
t.Fatalf("community socials: %v", body["socials"])
}
}
+604
View File
@@ -0,0 +1,604 @@
package api
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/observability"
)
const (
meExportHTMLBase int64 = 8 * 1024
meExportHTMLPerMessage int64 = 512
)
func (s *Server) handleMeExport(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
writeErr(w, http.StatusUnauthorized, "ابتدا وارد شوید")
return
}
partnerID, ok := meExportChatID(r)
if !ok {
writeErr(w, http.StatusBadRequest, "گفتگو را مشخص کنید")
return
}
if !s.canChatWith(r.Context(), u, partnerID) {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return
}
if r.URL.Query().Get("meta") == "1" {
n, last, err := s.estimateMeExportBytes(r.Context(), u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "برآورد حجم خروجی ممکن نشد")
return
}
payload := map[string]any{"bytes": n, "chat": partnerID}
if last != nil && !last.IsZero() {
payload["lastAt"] = last.UTC()
}
writeJSON(w, http.StatusOK, payload)
return
}
if err := s.writeMeChatHTMLZip(w, r, u, partnerID); err != nil {
observability.L().Error("me.export_zip", "error", err, "user_id", u.ID, "chat", partnerID)
}
}
func meExportChatID(r *http.Request) (int64, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("chat"))
if raw == "" {
return 0, false
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
func (s *Server) estimateMeExportBytes(ctx context.Context, userID, partnerID int64) (int64, *time.Time, error) {
var messages, media int64
var last *time.Time
err := s.store.Pool().QueryRow(ctx, `
SELECT
count(*)::bigint,
COALESCE(SUM(
CASE
WHEN attachments IS NOT NULL
AND jsonb_typeof(attachments) = 'array'
AND jsonb_array_length(attachments) > 0
THEN COALESCE((
SELECT SUM(GREATEST(COALESCE((elem->>'size')::bigint, 0), 0))
FROM jsonb_array_elements(attachments) AS elem
), 0)
ELSE GREATEST(COALESCE(attachment_size, 0), 0)
END
), 0)::bigint,
MAX(created_at)
FROM chat_messages
WHERE (user_id = $1 AND mentor_id = $2) OR (user_id = $2 AND mentor_id = $1)`, userID, partnerID).Scan(&messages, &media, &last)
if err != nil {
return 0, nil, err
}
return media + meExportHTMLBase + messages*meExportHTMLPerMessage, last, nil
}
type meExportMessage struct {
ID int64
UserID int64
MentorID int64
SenderRole string
Body string
CreatedAt time.Time
StudentName string
MentorName string
Atts []models.Attachment
}
type meExportFile struct {
Path string
Key string
}
type mePlaced struct {
msg meExportMessage
rel []string
}
func (s *Server) writeMeChatHTMLZip(w http.ResponseWriter, r *http.Request, u *models.User, partnerID int64) error {
title := "پیام‌های ذخیره‌شده"
if partnerID != u.ID {
partner, err := s.store.GetUserByID(r.Context(), partnerID)
if err != nil {
writeErr(w, http.StatusNotFound, "گفتگو پیدا نشد")
return err
}
title = strings.TrimSpace(partner.Name)
if title == "" {
title = "گفتگو"
}
}
rows, err := s.store.Pool().Query(r.Context(), `
SELECT m.id, m.user_id, m.mentor_id, m.sender_role, m.body, m.created_at,
m.attachment_type, m.attachment_url, m.attachment_name, m.attachment_size, m.attachments,
COALESCE(su.name, ''), COALESCE(mu.name, '')
FROM chat_messages m
LEFT JOIN users su ON su.id = m.user_id
LEFT JOIN users mu ON mu.id = m.mentor_id
WHERE (m.user_id = $1 AND m.mentor_id = $2) OR (m.user_id = $2 AND m.mentor_id = $1)
ORDER BY m.created_at ASC, m.id ASC`, u.ID, partnerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
defer rows.Close()
var messages []meExportMessage
for rows.Next() {
var m meExportMessage
var attType, attURL, attName *string
var attSize *int64
var rawAtts []byte
if err := rows.Scan(
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.CreatedAt,
&attType, &attURL, &attName, &attSize, &rawAtts,
&m.StudentName, &m.MentorName,
); err != nil {
continue
}
m.Atts = decodeExportAttachments(rawAtts, attType, attURL, attName, attSize)
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
placed := make([]mePlaced, 0, len(messages))
var files []meExportFile
seen := map[string]bool{}
for _, m := range messages {
item := mePlaced{msg: m, rel: make([]string, len(m.Atts))}
for i, att := range m.Atts {
key := mediaKeyFromURL(att.URL)
if key == "" || strings.Contains(key, "..") || !exportKeyAllowed(key, u.ID, m.UserID, m.MentorID) || !s.storage.Exists(key) {
continue
}
label := safeExportFilename(att.Name)
rel := filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-%s", m.ID, i+1, label)))
if seen[rel] {
rel = filepath.ToSlash(filepath.Join("media", fmt.Sprintf("%d-%d-b-%s", m.ID, i+1, label)))
}
seen[rel] = true
item.rel[i] = rel
files = append(files, meExportFile{Path: rel, Key: key})
}
placed = append(placed, item)
}
doc := renderMeExportHTML(u, title, placed)
tmp, err := os.CreateTemp("", "pargar-chats-*.zip")
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
zw := zip.NewWriter(tmp)
hw, err := zw.CreateHeader(&zip.FileHeader{
Name: "index.html",
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := io.WriteString(hw, doc); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
for _, f := range files {
rc, _, _, err := s.storage.Open(f.Key)
if err != nil {
continue
}
aw, err := zw.CreateHeader(&zip.FileHeader{
Name: f.Path,
Method: zip.Deflate,
Modified: time.Now().UTC(),
})
if err != nil {
_ = rc.Close()
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
_, copyErr := io.Copy(aw, rc)
_ = rc.Close()
if copyErr != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return copyErr
}
}
if err := zw.Close(); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
info, err := tmp.Stat()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت خروجی گفتگوها ممکن نشد")
return err
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="pargar-chat-%d.zip"`, partnerID))
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 exportKeyAllowed(key string, me, studentID, mentorID int64) bool {
return mediaKeyOwnedByUser(key, me) || mediaKeyOwnedByUser(key, studentID) || mediaKeyOwnedByUser(key, mentorID)
}
func decodeExportAttachments(raw []byte, attType, attURL, attName *string, attSize *int64) []models.Attachment {
if len(raw) > 0 && string(raw) != "null" && string(raw) != "[]" {
var list []models.Attachment
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return list
}
}
if attURL != nil && *attURL != "" {
size := int64(0)
if attSize != nil {
size = *attSize
}
typ, name := "", ""
if attType != nil {
typ = *attType
}
if attName != nil {
name = *attName
}
return []models.Attachment{{Type: typ, URL: *attURL, Name: name, Size: size}}
}
return nil
}
func renderMeExportHTML(owner *models.User, title string, messages []mePlaced) string {
var b strings.Builder
b.WriteString(`<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #eceef8;
color: #2a2745;
font-family: Vazirmatn, "IRANSans", Tahoma, sans-serif;
}
.sheet {
max-width: 440px;
margin: 24px auto 40px;
overflow: hidden;
background: #f7f7fc;
border: 1px solid rgba(255,255,255,.75);
border-radius: 26px;
box-shadow: 0 24px 70px -18px rgba(42,39,69,.35);
}
header.bar {
position: relative;
padding: 16px 16px 12px;
background: rgba(255,255,255,.78);
border-bottom: 1px solid #e3e5f2;
}
header.bar::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(to left, #6d63f0, #ec6fa9, #f2a437);
}
header.bar h1 { margin: 0; font-size: .95rem; font-weight: 800; }
header.bar p { margin: 3px 0 0; color: #6b6c8a; font-size: .75rem; }
.log { min-height: 240px; padding: 12px 12px 18px; }
.day { display: flex; justify-content: center; margin: 10px 0 8px; }
.day span {
border: 1px solid #d9dbe9;
background: rgba(255,255,255,.9);
color: #6b6c8a;
border-radius: 999px;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
}
article.msg { display: flex; margin: 0 0 6px; }
article.msg.mine { justify-content: flex-start; }
article.msg.theirs { justify-content: flex-end; }
.stack { display: flex; flex-direction: column; max-width: min(78%, 20rem); }
article.mine .stack { align-items: flex-start; }
article.theirs .stack { align-items: flex-end; }
.who { margin: 0 6px 3px; font-size: 11px; font-weight: 800; color: #6b6c8a; }
.bubble {
width: fit-content;
max-width: 100%;
padding: 8px 14px;
font-size: .875rem;
line-height: 1.65;
}
article.mine .bubble {
background: #6d63f0;
color: #fff;
border-radius: 18px 6px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.12);
}
article.theirs .bubble {
background: #fff;
color: #2a2745;
border: 1px solid #d9dbe9;
border-radius: 6px 18px 18px 18px;
box-shadow: 0 1px 1px rgba(0,0,0,.06);
}
.body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
unicode-bidi: plaintext;
}
time { display: block; margin-top: 4px; font-size: 10px; line-height: 1; opacity: .78; }
article.mine time { text-align: right; }
article.theirs time { text-align: left; }
img, video {
display: block;
max-width: min(100%, 240px);
height: auto;
border-radius: 12px;
margin-top: 6px;
}
audio { display: block; width: min(100%, 240px); margin-top: 6px; }
.file, .missing { margin: 6px 0 0; font-size: .82rem; }
article.mine a { color: #fff; }
article.theirs a { color: #4c45c6; }
.missing { opacity: .85; }
.empty { margin: 28px 8px; text-align: center; color: #6b6c8a; font-size: .875rem; }
</style>
</head>
<body>
<main class="sheet">
<header class="bar">
<h1>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</h1>
<p>خروجی پرگار · `)
b.WriteString(html.EscapeString(formatJalaliStamp(time.Now())))
b.WriteString(`</p>
</header>
<div class="log">
`)
if len(messages) == 0 {
b.WriteString(`<p class="empty">هنوز پیامی در این گفتگو نیست.</p>`)
}
var hasPrev bool
var prevMine bool
var prevAt time.Time
for _, item := range messages {
mine := meExportIsMine(owner.ID, item.msg)
if !hasPrev || !sameExportDay(prevAt, item.msg.CreatedAt) {
b.WriteString(`<div class="day"><span>`)
b.WriteString(html.EscapeString(formatJalaliDay(item.msg.CreatedAt)))
b.WriteString(`</span></div>`)
}
showName := !hasPrev || prevMine != mine || !sameExportDay(prevAt, item.msg.CreatedAt)
cls := "theirs"
if mine {
cls = "mine"
}
b.WriteString(`<article class="msg ` + cls + `"><div class="stack">`)
if showName {
b.WriteString(`<p class="who">`)
b.WriteString(html.EscapeString(meExportSenderName(owner, item.msg)))
b.WriteString(`</p>`)
}
b.WriteString(`<div class="bubble">`)
if strings.TrimSpace(item.msg.Body) != "" {
b.WriteString(`<p class="body">` + escapeExportBody(item.msg.Body) + `</p>`)
}
for i, att := range item.msg.Atts {
rel := ""
if i < len(item.rel) {
rel = item.rel[i]
}
b.WriteString(renderExportAttachment(att, rel))
}
b.WriteString(`<time datetime="` + html.EscapeString(item.msg.CreatedAt.UTC().Format(time.RFC3339)) + `">`)
b.WriteString(html.EscapeString(formatJalaliStamp(item.msg.CreatedAt)))
b.WriteString(`</time></div></div></article>`)
hasPrev = true
prevMine = mine
prevAt = item.msg.CreatedAt
}
b.WriteString(`</div>
</main>
</body>
</html>
`)
return b.String()
}
func meExportSenderName(me *models.User, m meExportMessage) string {
if meExportIsMine(me.ID, m) {
name := strings.TrimSpace(me.Name)
if name == "" {
return "شما"
}
return name
}
name := m.StudentName
if me.ID == m.UserID {
name = m.MentorName
}
name = strings.TrimSpace(name)
if name == "" {
return "گفتگو"
}
return name
}
func sameExportDay(a, b time.Time) bool {
al := a.In(tehranLocation())
bl := b.In(tehranLocation())
return al.Year() == bl.Year() && al.Month() == bl.Month() && al.Day() == bl.Day()
}
func formatJalaliDay(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month
}
func meExportIsMine(meID int64, m meExportMessage) bool {
if m.UserID == m.MentorID {
return true
}
role := models.Role(m.SenderRole)
if meID == m.UserID && role == models.RoleStudent {
return true
}
if meID == m.MentorID && (role == models.RoleMentor || role == models.RoleAdmin) {
return true
}
return false
}
func escapeExportBody(s string) string {
return html.EscapeString(s)
}
func renderExportAttachment(att models.Attachment, rel string) string {
name := strings.TrimSpace(att.Name)
if name == "" {
name = "فایل"
}
safe := html.EscapeString(name)
if rel == "" {
return `<p class="missing">فایل همراه موجود نیست: ` + safe + `</p>`
}
src := html.EscapeString(rel)
switch strings.ToLower(strings.TrimSpace(att.Type)) {
case "image":
return `<img src="` + src + `" alt="` + safe + `">`
case "audio":
return `<audio controls preload="metadata" src="` + src + `"></audio>`
case "video":
return `<video controls preload="metadata" src="` + src + `"></video>`
default:
return `<p class="file"><a href="` + src + `">` + safe + `</a></p>`
}
}
func formatJalaliStamp(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.In(tehranLocation())
_, jm, jd := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
month := "ماه"
if jm >= 1 && jm <= len(jalaliMonthNames) {
month = jalaliMonthNames[jm-1]
}
return persianDigits(jd) + " " + month + " · " + persianDigits2(local.Hour()) + ":" + persianDigits2(local.Minute())
}
func tehranLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("IRST", 3*3600+30*60)
}
return loc
}
var jalaliMonthNames = []string{
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
}
func persianDigits(n int) string {
if n < 0 {
n = 0
}
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return '۰' + (r - '0')
}
return r
}, strconv.Itoa(n))
}
func persianDigits2(n int) string {
if n < 0 {
n = 0
}
return persianDigits(n/10) + persianDigits(n%10)
}
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
gdm := [12]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}
gy2 := gy
if gm > 2 {
gy2 = gy + 1
}
days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1]
jy = -1595 + 33*(days/12053)
days %= 12053
jy += 4 * (days / 1461)
days %= 1461
if days > 365 {
jy += (days - 1) / 365
days = (days - 1) % 365
}
if days < 186 {
jm = 1 + days/31
jd = 1 + days%31
return jy, jm, jd
}
jm = 7 + (days-186)/30
jd = 1 + (days-186)%30
return jy, jm, jd
}
+254
View File
@@ -0,0 +1,254 @@
package api_test
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"strings"
"testing"
)
func TestMeChatHTMLZipExport(t *testing.T) {
e := setup(t)
student, studentID := register(t, e, "Export Student", "me-export@test.dev")
other, otherID := register(t, e, "Other Student", "me-export-other@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")
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
if err != nil || len(png) < 8 {
t.Fatalf("png: %v len=%d", err, len(png))
}
wav := make([]byte, 44)
copy(wav, []byte("RIFF"))
copy(wav[8:], []byte("WAVE"))
note := []byte("note-bytes")
code, upImg := uploadChatFile(t, e, student, "shot.png", png)
if code != http.StatusCreated {
t.Fatalf("png upload: %d %v", code, upImg)
}
code, upNote := uploadChatFile(t, e, student, "notes.txt", note)
if code != http.StatusCreated {
t.Fatalf("txt upload: %d %v", code, upNote)
}
code, upVoice := uploadChatFile(t, e, student, "voice-note.wav", wav)
if code != http.StatusCreated {
t.Fatalf("wav upload: %d %v", code, upVoice)
}
code, sent := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{
"body": "<b>راز</b>\nخط دوم",
"attachments": []any{upImg["attachment"], upNote["attachment"]},
})
if code != http.StatusCreated {
t.Fatalf("send: %d %v", code, sent)
}
msgID := int64(sent["message"].(map[string]any)["id"].(float64))
if _, err := e.pool.Exec(context.Background(), `UPDATE chat_messages SET created_at = $1 WHERE id = $2`, "2026-03-21 12:00:00+00", msgID); err != nil {
t.Fatal(err)
}
code, voice := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{
"body": "صدا",
"attachment": upVoice["attachment"],
})
if code != http.StatusCreated {
t.Fatalf("voice: %d %v", code, voice)
}
if _, err := e.pool.Exec(context.Background(), `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size)
VALUES ($1, $2, 'student', 'گمشده', 'audio', 'http://localhost:8080/media/chats/999-missing.wav', 'gone.wav', 5000)`,
studentID, mentorID); err != nil {
t.Fatal(err)
}
code, saved := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", student, map[string]any{"body": "یادداشت خصوصی"})
if code != http.StatusCreated {
t.Fatalf("saved: %d %v", code, saved)
}
code, reply := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", mentor, map[string]any{"body": "از منتور"})
if code != http.StatusCreated {
t.Fatalf("reply: %d %v", code, reply)
}
code, _ = e.do(t, "GET", "/api/me/export?meta=1", student, nil)
if code != http.StatusBadRequest {
t.Fatalf("meta without chat: %d", code)
}
code, _, raw := e.doRaw(t, "GET", "/api/me/export", student, nil)
if code != http.StatusBadRequest || (len(raw) >= 2 && raw[0] == 'P' && raw[1] == 'K') {
t.Fatalf("export without chat should not be a zip: %d", code)
}
code, meta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(mentorID), student, nil)
if code != http.StatusOK {
t.Fatalf("meta: %d %v", code, meta)
}
approx := int64(meta["bytes"].(float64))
floor := int64(len(png) + len(wav) + len(note) + 5000)
if approx < floor {
t.Fatalf("estimate %d < media floor %d", approx, floor)
}
if int64(meta["chat"].(float64)) != mentorID {
t.Fatalf("meta chat: %v", meta["chat"])
}
code, savedMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), student, nil)
if code != http.StatusOK || int64(savedMeta["bytes"].(float64)) >= approx {
t.Fatalf("saved meta should be only that chat: %d %v", code, savedMeta)
}
code, ct, raw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), student, nil)
if code != http.StatusOK {
t.Fatalf("zip: %d %s", code, ct)
}
if ct != "application/zip" || len(raw) < 2 || raw[0] != 'P' || raw[1] != 'K' {
t.Fatalf("not a zip: %s %d", ct, len(raw))
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
var htmlDoc string
var sawImage, sawAudio, sawNote bool
for _, f := range zr.File {
if strings.Contains(f.Name, "gone.wav") || strings.Contains(f.Name, "missing") {
t.Fatalf("missing media was packed: %s", f.Name)
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
switch {
case f.Name == "index.html":
htmlDoc = string(blob)
case strings.HasSuffix(f.Name, ".png"):
sawImage = bytes.Equal(blob, png)
case strings.HasSuffix(f.Name, ".wav"):
sawAudio = bytes.Equal(blob, wav)
case strings.HasSuffix(f.Name, ".txt"):
sawNote = bytes.Equal(blob, note)
}
}
if htmlDoc == "" || !sawImage || !sawAudio || !sawNote {
t.Fatalf("zip parts image=%v audio=%v note=%v html=%d", sawImage, sawAudio, sawNote, len(htmlDoc))
}
if !strings.Contains(htmlDoc, `lang="fa"`) || !strings.Contains(htmlDoc, `dir="rtl"`) {
t.Fatal("html is not a persian rtl document")
}
if strings.Contains(htmlDoc, "<b>راز") || !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;") {
t.Fatal("user text was not escaped")
}
if !strings.Contains(htmlDoc, "&lt;b&gt;راز&lt;/b&gt;\nخط دوم") {
t.Fatal("newline was not kept in the bubble")
}
if !strings.Contains(htmlDoc, "white-space: pre-wrap") {
t.Fatal("bubble text does not keep whitespace")
}
if !strings.Contains(htmlDoc, `class="msg mine"`) || !strings.Contains(htmlDoc, `class="msg theirs"`) {
t.Fatal("own and partner bubbles are missing")
}
if !strings.Contains(htmlDoc, "#6d63f0") {
t.Fatal("own bubble is not the chat primary color")
}
if !strings.Contains(htmlDoc, ">Mentor Export</h1>") || !strings.Contains(htmlDoc, "Export Student") || !strings.Contains(htmlDoc, "از منتور") {
t.Fatal("single conversation header or sender names missing")
}
if strings.Contains(htmlDoc, "یادداشت خصوصی") || strings.Contains(htmlDoc, "گفتگوهای پرگار") {
t.Fatal("export included another conversation")
}
if !strings.Contains(htmlDoc, "<img ") || !strings.Contains(htmlDoc, `src="media/`) {
t.Fatal("image is not inlined with a relative src")
}
if !strings.Contains(htmlDoc, "<audio controls") {
t.Fatal("voice has no native audio control")
}
if !strings.Contains(htmlDoc, "<a href=\"media/") {
t.Fatal("other file is not a relative link")
}
if !strings.Contains(htmlDoc, "۱ فروردین · ۱۵:۳۰") {
t.Fatal("jalali stamp missing")
}
if !strings.Contains(htmlDoc, "فایل همراه موجود نیست") || !strings.Contains(htmlDoc, "gone.wav") {
t.Fatal("missing file was not reported in the transcript")
}
code, ct, savedRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), student, nil)
if code != http.StatusOK || ct != "application/zip" || len(savedRaw) < 2 || savedRaw[0] != 'P' {
t.Fatalf("saved zip: %d %s", code, ct)
}
savedZip, err := zip.NewReader(bytes.NewReader(savedRaw), int64(len(savedRaw)))
if err != nil {
t.Fatal(err)
}
var savedHTML string
for _, f := range savedZip.File {
if strings.HasSuffix(f.Name, ".png") || strings.HasSuffix(f.Name, ".wav") || strings.HasSuffix(f.Name, ".txt") {
t.Fatalf("saved chat packed another conversation's media: %s", f.Name)
}
if f.Name != "index.html" {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
savedHTML = string(blob)
}
if !strings.Contains(savedHTML, "پیام‌های ذخیره‌شده") || !strings.Contains(savedHTML, "یادداشت خصوصی") {
t.Fatal("saved chat export is missing its own transcript")
}
if strings.Contains(savedHTML, "راز") || strings.Contains(savedHTML, "از منتور") {
t.Fatal("saved chat export included the mentor thread")
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(studentID), other, nil)
if code != http.StatusNotFound {
t.Fatalf("other user chat: %d", code)
}
code, _, otherRaw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(otherID), other, nil)
if code != http.StatusOK || len(otherRaw) < 2 || otherRaw[0] != 'P' {
t.Fatalf("other user own export failed: %d", code)
}
otherZip, err := zip.NewReader(bytes.NewReader(otherRaw), int64(len(otherRaw)))
if err != nil {
t.Fatal(err)
}
for _, f := range otherZip.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
blob, _ := io.ReadAll(rc)
_ = rc.Close()
if bytes.Contains(blob, []byte("راز")) || bytes.Contains(blob, []byte("یادداشت خصوصی")) {
t.Fatalf("other user export leaked %s", f.Name)
}
}
code, mentorMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), mentor, nil)
if code != http.StatusOK || int64(mentorMeta["bytes"].(float64)) < floor {
t.Fatalf("mentor meta: %d %v", code, mentorMeta)
}
code, _ = e.do(t, "GET", "/api/me/export?chat=999999", student, nil)
if code != http.StatusNotFound {
t.Fatalf("missing partner: %d", code)
}
code, _, _ = e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), "", nil)
if code != http.StatusUnauthorized {
t.Fatalf("anon: %d", code)
}
}
@@ -0,0 +1,27 @@
package api
import (
"testing"
"time"
)
func TestGregorianToJalaliKnownDates(t *testing.T) {
cases := []struct {
y, m, d int
jy, jm, jd int
}{
{2026, 3, 21, 1405, 1, 1},
{2026, 10, 10, 1405, 7, 18},
{2024, 3, 20, 1403, 1, 1},
}
for _, c := range cases {
jy, jm, jd := gregorianToJalali(c.y, c.m, c.d)
if jy != c.jy || jm != c.jm || jd != c.jd {
t.Fatalf("%04d-%02d-%02d: got %d-%d-%d want %d-%d-%d", c.y, c.m, c.d, jy, jm, jd, c.jy, c.jm, c.jd)
}
}
stamp := formatJalaliStamp(time.Date(2026, 3, 21, 12, 0, 0, 0, time.UTC))
if stamp != "۱ فروردین · ۱۵:۳۰" {
t.Fatalf("stamp: %q", stamp)
}
}
+801
View File
@@ -0,0 +1,801 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
var announcementCategories = map[string]bool{
"event": true, "progress": true, "gamification": true, "mentor": true,
"urgent": true, "curriculum": true, "community": true, "system": true,
}
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
s.createAnnouncement(w, r)
}
func (s *Server) handleAdminListAnnouncements(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListAnnouncements(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری اطلاعیه‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"announcements": announceViews(list)})
}
func (s *Server) handleAdminUpdateAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.ID = id
if err := s.store.UpdateAnnouncement(r.Context(), a); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "ویرایش اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if err := s.store.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "اطلاعیه پیدا نشد")
return
}
writeErr(w, http.StatusInternalServerError, "حذف اطلاعیه ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handlePinnedAnnouncement(w http.ResponseWriter, r *http.Request) {
list, err := s.store.RecentAnnouncements(r.Context(), 8)
var pinned any
if err == nil {
for _, a := range list {
if a.Pinned {
pinned = announceViews([]store.Announcement{a})[0]
break
}
}
}
if pinned == nil {
if a, perr := s.store.PinnedAnnouncement(r.Context()); perr == nil {
pinned = announceViews([]store.Announcement{*a})[0]
}
}
views := []map[string]any{}
if err == nil {
views = announceViews(list)
}
writeJSON(w, http.StatusOK, map[string]any{"announcement": pinned, "announcements": views})
}
func (s *Server) createAnnouncement(w http.ResponseWriter, r *http.Request) {
a, ok := s.readAnnouncement(w, r)
if !ok {
return
}
a.CreatedBy = currentUser(r).ID
if err := s.store.CreateAnnouncement(r.Context(), &a); err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت اطلاعیه ممکن نشد")
return
}
ids, err := s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
go func() {
for _, uid := range ids {
n := &models.Notification{
UserID: uid, Category: a.Category, Type: "announcement",
Title: a.Title, Body: a.Body, Route: a.Route,
}
if err := s.store.CreateNotification(context.Background(), n); err == nil {
_ = s.store.AttachAnnouncement(context.Background(), n.ID, a.ID)
}
}
}()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": a.ID, "recipients": len(ids)})
}
func (s *Server) readAnnouncement(w http.ResponseWriter, r *http.Request) (store.Announcement, bool) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
Route string `json:"route"`
Category string `json:"category"`
Pinned bool `json:"pinned"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return store.Announcement{}, false
}
req.Title = strings.TrimSpace(req.Title)
req.Body = strings.TrimSpace(req.Body)
req.Route = strings.TrimSpace(req.Route)
if req.Category == "" {
req.Category = "event"
}
if !announcementCategories[req.Category] {
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if utf8.RuneCountInString(req.Title) < 3 || utf8.RuneCountInString(req.Title) > 200 {
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if utf8.RuneCountInString(req.Body) < 8 || utf8.RuneCountInString(req.Body) > 4000 {
writeErr(w, http.StatusBadRequest, "متن اطلاعیه نامعتبر است")
return store.Announcement{}, false
}
if req.Route != "" && !strings.HasPrefix(req.Route, "/") {
writeErr(w, http.StatusBadRequest, "مسیر باید با / شروع شود")
return store.Announcement{}, false
}
return store.Announcement{
Title: req.Title, Body: req.Body, Category: req.Category, Route: req.Route, Pinned: req.Pinned,
}, true
}
func announceViews(list []store.Announcement) []map[string]any {
if list == nil {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(list))
for _, a := range list {
out = append(out, map[string]any{
"id": a.ID, "title": a.Title, "body": a.Body, "category": a.Category,
"route": a.Route, "pinned": a.Pinned, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt,
})
}
return out
}
func (s *Server) handleChatPrefs(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), me, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
var req struct {
PinnedRank *int `json:"pinnedRank"`
Muted bool `json:"muted"`
Archived bool `json:"archived"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.UpsertChatPref(r.Context(), me.ID, partner, req.PinnedRank, req.Muted, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره تنظیم گفتگو ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatMuteAll(w http.ResponseWriter, r *http.Request) {
var req struct {
Muted bool `json:"muted"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.SetChatMutedAll(r.Context(), currentUser(r).ID, req.Muted); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "muted": req.Muted})
}
func (s *Server) handleChatPeek(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if !s.canChatWith(r.Context(), me, partner) {
writeErr(w, http.StatusForbidden, "شما اجازهٔ گفتگو با این کاربر را ندارید")
return
}
msgs, err := s.store.ListChatMessages(r.Context(), me.ID, partner, 0, 8)
if err != nil {
writeErr(w, http.StatusInternalServerError, "پیش‌نمایش ممکن نشد")
return
}
if len(msgs) > 8 {
msgs = msgs[len(msgs)-8:]
}
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
}
func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if err := s.store.MarkChatUnread(r.Context(), me.ID, partner, me.Role.String()); err != nil {
writeErr(w, http.StatusInternalServerError, "بازگشت به نخوانده ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatDeleteBoth(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
partner := routeID(r, "partner")
if r.URL.Query().Get("confirm") != "both" {
writeErr(w, http.StatusBadRequest, "برای پاک کردن دو طرفه confirm=both لازم است")
return
}
if !s.canChatWith(r.Context(), me, partner) && me.Role != models.RoleAdmin {
writeErr(w, http.StatusForbidden, "اجازه ندارید")
return
}
if err := s.store.DeleteChatBoth(r.Context(), me.ID, partner); err != nil {
writeErr(w, http.StatusInternalServerError, "حذف گفتگو ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleChatBroadcast(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
if !me.Role.IsStaff() {
writeErr(w, http.StatusForbidden, "فقط مسئول دوره می‌تواند پیام همگانی بفرستد")
return
}
var req struct {
Body string `json:"body"`
UserIDs []int64 `json:"userIds"`
All bool `json:"all"`
}
if err := bodyJSON(r, &req); err != nil || strings.TrimSpace(req.Body) == "" {
writeErr(w, http.StatusBadRequest, "متن پیام لازم است")
return
}
ids := req.UserIDs
if req.All {
var err error
ids, err = s.store.AllActiveUserIDs(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
return
}
}
sent := 0
for _, id := range ids {
if id == me.ID || !s.canChatWith(r.Context(), me, id) {
continue
}
studentID, mentorID := id, me.ID
if me.Role == models.RoleStudent {
studentID, mentorID = me.ID, id
}
if _, err := s.store.AddChatMessage(r.Context(), studentID, mentorID, me.Role, strings.TrimSpace(req.Body), nil, nil); err == nil {
sent++
}
}
writeJSON(w, http.StatusOK, map[string]any{"sent": sent})
}
func (s *Server) handleSaveMessage(w http.ResponseWriter, r *http.Request) {
var req struct {
Body string `json:"body"`
AttachmentURL string `json:"attachmentUrl"`
AttachmentType string `json:"attachmentType"`
AttachmentName string `json:"attachmentName"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if strings.TrimSpace(req.Body) == "" && req.AttachmentURL == "" {
writeErr(w, http.StatusBadRequest, "پیامی برای ذخیره نیست")
return
}
me := currentUser(r)
var att *models.Attachment
if req.AttachmentURL != "" {
url := req.AttachmentURL
if q := strings.Index(url, "?"); q >= 0 {
url = url[:q]
}
att = &models.Attachment{Type: req.AttachmentType, URL: url, Name: req.AttachmentName}
}
var saved []models.Attachment
if att != nil {
saved = []models.Attachment{*att}
}
if _, err := s.store.AddChatMessage(r.Context(), me.ID, me.ID, me.Role, strings.TrimSpace(req.Body), nil, saved); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleListSaved(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSaved(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"messages": list})
}
func (s *Server) handleGetMeProfile(w http.ResponseWriter, r *http.Request) {
p, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(p)})
}
func (s *Server) handlePutMeProfile(w http.ResponseWriter, r *http.Request) {
var req struct {
Banner *string `json:"banner"`
Public *bool `json:"public"`
ShowActivity *bool `json:"showActivity"`
ShowStats *bool `json:"showStats"`
ShowPath *bool `json:"showPath"`
ShowBadges *bool `json:"showBadges"`
SocialLinks json.RawMessage `json:"socialLinks"`
Theme *string `json:"theme"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
cur, err := s.store.GetProfileExtras(r.Context(), currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
if req.Banner != nil {
cur.Banner = strings.TrimSpace(*req.Banner)
}
if req.Public != nil {
cur.Public = *req.Public
}
if req.ShowActivity != nil {
cur.ShowActivity = *req.ShowActivity
}
if req.ShowStats != nil {
cur.ShowStats = *req.ShowStats
}
if req.ShowPath != nil {
cur.ShowPath = *req.ShowPath
}
if req.ShowBadges != nil {
cur.ShowBadges = *req.ShowBadges
}
if req.Theme != nil {
cur.Theme = strings.TrimSpace(*req.Theme)
}
if len(req.SocialLinks) > 0 {
var links []map[string]string
if err := json.Unmarshal(req.SocialLinks, &links); err != nil || len(links) > 5 {
writeErr(w, http.StatusBadRequest, "حداکثر ۵ لینک شخصی مجاز است")
return
}
cur.SocialLinks = req.SocialLinks
}
if i := strings.Index(cur.Banner, "?"); i >= 0 {
cur.Banner = cur.Banner[:i]
}
if err := s.store.SaveProfileExtras(r.Context(), currentUser(r).ID, cur); err != nil {
writeErr(w, http.StatusInternalServerError, "ذخیره پروفایل ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"profile": s.profileView(cur)})
}
func (s *Server) profileView(p store.ProfileExtras) map[string]any {
links := json.RawMessage(p.SocialLinks)
if len(links) == 0 {
links = json.RawMessage(`[]`)
}
banner := p.Banner
if i := strings.Index(banner, "?"); i >= 0 {
banner = banner[:i]
}
return map[string]any{
"banner": s.signExistingMediaURL(banner), "public": p.Public, "showActivity": p.ShowActivity,
"showStats": p.ShowStats, "showPath": p.ShowPath, "showBadges": p.ShowBadges,
"socialLinks": json.RawMessage(links), "theme": p.Theme,
}
}
func (s *Server) handleMeAchievements(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{"achievements": s.achievementsFor(r.Context(), u)})
}
func (s *Server) achievementsFor(ctx context.Context, u *models.User) []map[string]any {
passed := 0
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, u.ID).Scan(&passed)
certs := 0
_ = s.store.Pool().QueryRow(ctx, `SELECT count(*) FROM certificates WHERE user_id=$1 AND revoked_at IS NULL`, u.ID).Scan(&certs)
items := []struct {
id, title string
on bool
}{
{"first_pass", "اولین قبولی", passed > 0},
{"chapter_done", "اتمام فصل", s.finishedChapter(ctx, u.ID)},
{"streak_7", "استمرار هفت‌روزه", u.StreakCurrent >= 7 || u.StreakLongest >= 7},
{"course_done", "پایان دوره", certs > 0},
}
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{"id": it.id, "title": it.title, "unlocked": it.on})
}
return out
}
func (s *Server) finishedChapter(ctx context.Context, userID int64) bool {
var n int
err := s.store.Pool().QueryRow(ctx, `
SELECT count(*) FROM chapters c
WHERE EXISTS (SELECT 1 FROM lessons l WHERE l.chapter_id=c.id)
AND NOT EXISTS (
SELECT 1 FROM lessons l
LEFT JOIN lesson_progress p ON p.lesson_id=l.id AND p.user_id=$1
WHERE l.chapter_id=c.id AND COALESCE(p.passed_quiz, false)=false
)`, userID).Scan(&n)
return err == nil && n > 0
}
func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.Pool().Query(r.Context(), `
SELECT day::text, count(*) FROM (
SELECT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
UNION ALL
SELECT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
) d
WHERE day >= current_date - 120
GROUP BY day ORDER BY day`, currentUser(r).ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری فعالیت ممکن نشد")
return
}
defer rows.Close()
var days []map[string]any
for rows.Next() {
var day string
var n int
if err := rows.Scan(&day, &n); err != nil {
continue
}
days = append(days, map[string]any{"day": day, "count": n})
}
if days == nil {
days = []map[string]any{}
}
week, month := 0, 0
_ = s.store.Pool().QueryRow(r.Context(), `
SELECT
count(*) FILTER (WHERE day >= current_date - 6),
count(*) FILTER (WHERE day >= current_date - 29)
FROM (
SELECT DISTINCT created_at::date AS day FROM quiz_attempts WHERE user_id=$1
UNION
SELECT DISTINCT created_at::date FROM chat_messages WHERE user_id=$1 OR mentor_id=$1
) d`, currentUser(r).ID).Scan(&week, &month)
writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if utf8.RuneCountInString(q) < 2 {
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
return
}
me := currentUser(r)
like := "%" + q + "%"
var results []map[string]any
rows, err := s.store.Pool().Query(r.Context(), `
SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)})
}
}
rows.Close()
}
rows, err = s.store.Pool().Query(r.Context(), `
SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like)
if err == nil {
for rows.Next() {
var id int64
var title string
if rows.Scan(&id, &title) == nil {
results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"})
}
}
rows.Close()
}
if me.Role.IsStaff() {
people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0)
for _, p := range people {
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
}
} else {
people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0)
n := 0
for _, p := range people {
if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
continue
}
results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)})
n++
}
}
pages := []struct{ title, href string }{
{"درخت مهارت", "/cap"}, {"اعلان‌ها", "/notifications"}, {"لیگ", "/leaderboard"},
{"تنظیمات", "/settings"}, {"پروفایل", "/profile"},
}
for _, p := range pages {
if strings.Contains(p.title, q) {
results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href})
}
}
if results == nil {
results = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
env := "production"
if s.cfg.DevMode {
env = "development"
}
version, built := appBuild()
writeJSON(w, http.StatusOK, map[string]any{
"version": version,
"built": built,
"env": env,
"health": "ok",
})
}
func appBuild() (version, built string) {
version = "0.2.0"
built = "dev"
info, ok := debug.ReadBuildInfo()
if !ok {
return version, built
}
for _, setting := range info.Settings {
if setting.Key == "vcs.time" && len(setting.Value) >= 10 {
built = setting.Value[:10]
}
if setting.Key == "vcs.revision" && len(setting.Value) >= 7 {
version = "0.2.0+" + setting.Value[:7]
}
}
return version, built
}
func (s *Server) handleAdminListBots(w http.ResponseWriter, r *http.Request) {
p := parsePageParams(r)
p.PageSize = 8
p.Offset = (p.Page - 1) * p.PageSize
var total int64
if err := s.store.Pool().QueryRow(r.Context(), `SELECT count(*) FROM bot_tokens`).Scan(&total); err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
return
}
rows, err := s.store.Pool().Query(r.Context(), `
SELECT bt.id, bt.bot_user_id, COALESCE(u.name, ''), bt.created_at, bt.revoked_at IS NULL, bt.request_count
FROM bot_tokens bt
JOIN users u ON u.id = bt.bot_user_id
ORDER BY bt.id DESC
LIMIT $1 OFFSET $2`, p.PageSize, p.Offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری کلیدها ممکن نشد")
return
}
defer rows.Close()
items := make([]map[string]any, 0)
for rows.Next() {
var id, botID, requestCount int64
var name string
var createdAt time.Time
var active bool
if rows.Scan(&id, &botID, &name, &createdAt, &active, &requestCount) != nil {
continue
}
items = append(items, map[string]any{
"id": id,
"botId": botID,
"name": name,
"createdAt": createdAt,
"active": active,
"requestCount": requestCount,
})
}
writePage(w, items, total, p)
}
func (s *Server) handleAdminSetBotActive(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
if id == 0 {
writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است")
return
}
var req struct {
Active bool `json:"active"`
}
if err := bodyJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
var err error
var updated int64
if req.Active {
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=NULL WHERE id=$1`, id)
err = execErr
updated = tag.RowsAffected()
} else {
tag, execErr := s.store.Pool().Exec(r.Context(), `UPDATE bot_tokens SET revoked_at=COALESCE(revoked_at, now()) WHERE id=$1`, id)
err = execErr
updated = tag.RowsAffected()
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی ممکن نشد")
return
}
if updated == 0 {
writeErr(w, http.StatusNotFound, "کلید پیدا نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "active": req.Active})
}
func (s *Server) handleCreateBot(w http.ResponseWriter, r *http.Request) {
me := currentUser(r)
var req struct {
Name string `json:"name"`
}
_ = bodyJSON(r, &req)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "ربات پرگار"
}
raw := make([]byte, 24)
_, _ = rand.Read(raw)
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
email := "bot-" + token[:8] + "@bots.pargar.local"
bot, err := s.store.CreateUser(r.Context(), name, email, "bot"+token[:6], hash, "", "", "", models.RoleMentor)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ساخت ربات ممکن نشد")
return
}
if _, err := s.store.Pool().Exec(r.Context(), `
INSERT INTO bot_tokens (bot_user_id, owner_id, token_hash) VALUES ($1,$2,$3)`, bot.ID, me.ID, hash); err != nil {
writeErr(w, http.StatusInternalServerError, "ثبت توکن ممکن نشد")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"botId": bot.ID, "token": token})
}
func (s *Server) botFromRequest(r *http.Request) (int64, int64, bool) {
token := strings.TrimSpace(r.Header.Get("X-Bot-Token"))
if token == "" {
return 0, 0, false
}
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
var botID, ownerID int64
var active bool
err := s.store.Pool().QueryRow(r.Context(), `
SELECT bot_user_id, owner_id, revoked_at IS NULL FROM bot_tokens WHERE token_hash=$1`, hash).Scan(&botID, &ownerID, &active)
if err != nil || !active {
return 0, 0, false
}
_, _ = s.store.Pool().Exec(r.Context(), `
UPDATE bot_tokens SET request_count = request_count + 1 WHERE token_hash=$1 AND revoked_at IS NULL`, hash)
return botID, ownerID, true
}
func (s *Server) handleBotSend(w http.ResponseWriter, r *http.Request) {
botID, _, ok := s.botFromRequest(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
return
}
var req struct {
UserID int64 `json:"userId"`
Text string `json:"text"`
Buttons []any `json:"buttons"`
}
if err := bodyJSON(r, &req); err != nil || req.UserID == 0 || strings.TrimSpace(req.Text) == "" {
writeErr(w, http.StatusBadRequest, "userId و text لازم است")
return
}
buttons, _ := json.Marshal(req.Buttons)
var id int64
err := s.store.Pool().QueryRow(r.Context(), `
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, buttons)
VALUES ($1,$2,'mentor',$3,$4) RETURNING id`,
req.UserID, botID, strings.TrimSpace(req.Text), buttons).Scan(&id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "ارسال ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"messageId": id})
}
func (s *Server) handleBotUpdates(w http.ResponseWriter, r *http.Request) {
botID, _, ok := s.botFromRequest(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "توکن ربات نامعتبر است")
return
}
after, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64)
rows, err := s.store.Pool().Query(r.Context(), `
SELECT id, payload, created_at FROM bot_updates WHERE bot_user_id=$1 AND id>$2 ORDER BY id LIMIT 50`, botID, after)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری ممکن نشد")
return
}
defer rows.Close()
var updates []map[string]any
for rows.Next() {
var id int64
var payload json.RawMessage
var at time.Time
if rows.Scan(&id, &payload, &at) == nil {
updates = append(updates, map[string]any{"id": id, "payload": json.RawMessage(payload), "createdAt": at})
}
}
if updates == nil {
updates = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"updates": updates})
}
func (s *Server) recordBotReply(ctx context.Context, from *models.User, partnerID int64, body string) {
var ownerID int64
err := s.store.Pool().QueryRow(ctx, `SELECT owner_id FROM bot_tokens WHERE bot_user_id=$1`, partnerID).Scan(&ownerID)
if err != nil {
return
}
payload, _ := json.Marshal(map[string]any{"from": from.ID, "name": from.Name, "text": body})
_, _ = s.store.Pool().Exec(ctx, `INSERT INTO bot_updates (bot_user_id, payload) VALUES ($1,$2)`, partnerID, payload)
_ = ownerID
}
func (s *Server) noteBotReply(r *http.Request, partner int64, body string) {
s.recordBotReply(r.Context(), currentUser(r), partner, body)
}
// silence unused import if pgx is only used by callers
var _ = pgx.ErrNoRows
+458
View File
@@ -0,0 +1,458 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
)
func TestAnnouncementRecordEditDeleteAndPin(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, _ := register(t, e, "Ann", "ann@test.dev")
code, created := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{
"title": "اطلاعیه تازه", "body": "متن **مارک‌داون** برای همه.", "category": "urgent", "route": "", "pinned": true,
})
if code != http.StatusOK {
t.Fatalf("create: %d %v", code, created)
}
id := int64(created["id"].(float64))
code, list := e.do(t, "GET", "/api/admin/announcements", admin, nil)
if code != http.StatusOK || len(list["announcements"].([]any)) == 0 {
t.Fatalf("list: %d %v", code, list)
}
code, pin := e.do(t, "GET", "/api/announcements/pinned", student, nil)
if code != http.StatusOK {
t.Fatalf("pin: %d %v", code, pin)
}
item := pin["announcement"].(map[string]any)
if item["title"] != "اطلاعیه تازه" {
t.Fatalf("pinned title: %v", item)
}
code, second := e.do(t, "POST", "/api/admin/announce", admin, map[string]any{
"title": "اطلاعیه دوم", "body": "متن دوم برای اسلاید.", "category": "event", "route": "", "pinned": false,
})
if code != http.StatusOK {
t.Fatalf("second: %d %v", code, second)
}
code, slidesRes := e.do(t, "GET", "/api/announcements/pinned", student, nil)
slides, _ := slidesRes["announcements"].([]any)
if code != http.StatusOK || len(slides) < 2 {
t.Fatalf("slides: %d %v", code, slidesRes["announcements"])
}
code, _ = e.do(t, "PUT", "/api/admin/announcements/"+itoa(id), admin, map[string]any{
"title": "اطلاعیه ویرایش‌شده", "body": "متن ویرایش‌شده برای رونوشت.", "category": "curriculum", "route": "/cap", "pinned": true,
})
if code != http.StatusOK {
t.Fatalf("update: %d", code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
code, pin = e.do(t, "GET", "/api/announcements/pinned", student, nil)
if code == http.StatusOK {
if got, _ := pin["announcement"].(map[string]any); got != nil && got["title"] == "اطلاعیه ویرایش‌شده" {
break
}
}
time.Sleep(50 * time.Millisecond)
}
code, _ = e.do(t, "DELETE", "/api/admin/announcements/"+itoa(id), admin, nil)
if code != http.StatusOK {
t.Fatalf("delete: %d", code)
}
}
func TestHeartsRegenerateAndDoNotLock(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Heart", "heart@test.dev")
admin := login(t, e, bootstrapAdminEmail)
code, _ := e.do(t, "PUT", "/api/admin/users/"+itoa(uid), admin, map[string]any{"hearts": 0})
if code != http.StatusOK {
t.Fatalf("set hearts: %d", code)
}
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET hearts_updated_at = now() - interval '5 hours' WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
if code != http.StatusOK {
t.Fatalf("me: %d", code)
}
user := me["user"].(map[string]any)
if user["hearts"].(float64) < 1 {
t.Fatalf("expected a returned heart, got %v", user["hearts"])
}
if user["isLocked"] == true {
t.Fatal("zero hearts must not lock by default")
}
code, _ = e.do(t, "PUT", "/api/admin/settings", admin, map[string]any{
"settings": map[string]string{"hearts_max": "5", "hearts_regen_minutes": "240", "hearts_lock_on_empty": "false"},
})
if code != http.StatusOK {
t.Fatalf("settings: %d", code)
}
}
func TestProfilePrivateByDefault(t *testing.T) {
e := setup(t)
viewer, _ := register(t, e, "Viewer", "viewer@test.dev")
_, other := register(t, e, "Hidden", "hidden@test.dev")
code, body := e.do(t, "GET", "/api/users/"+itoa(other), viewer, nil)
if code != http.StatusOK {
t.Fatalf("profile: %d %v", code, body)
}
user := body["user"].(map[string]any)
if user["private"] != true {
t.Fatalf("expected private profile, got %v", user)
}
if _, ok := user["xp"]; ok {
t.Fatalf("private profile leaked xp: %v", user)
}
}
func TestMentorDirectoryUsesRealRoles(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, uid := register(t, e, "StudentRow", "row@test.dev")
code, body := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, body)
}
found := false
for _, raw := range body["mentors"].([]any) {
row := raw.(map[string]any)
if int64(row["id"].(float64)) == uid {
found = true
if row["role"] != "student" {
t.Fatalf("role: %v", row["role"])
}
}
}
if !found {
t.Fatal("student missing from staff directory")
}
}
func TestSavedMessageBecomesSelfChat(t *testing.T) {
e := setup(t)
token, uid := register(t, e, "Saver", "saver@test.dev")
code, body := e.do(t, "POST", "/api/me/saved", token, map[string]any{"body": "برای بعد"})
if code != http.StatusOK {
t.Fatalf("save: %d %v", code, body)
}
code, body = e.do(t, "GET", "/api/chats/"+itoa(uid)+"/messages", token, nil)
if code != http.StatusOK {
t.Fatalf("self chat: %d %v", code, body)
}
msgs, _ := body["messages"].([]any)
if len(msgs) == 0 {
t.Fatal("saved text missing from self chat")
}
last := msgs[len(msgs)-1].(map[string]any)
if last["body"] != "برای بعد" {
t.Fatalf("body: %v", last["body"])
}
}
func TestBroadcastCanTargetSomePeople(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, sid := register(t, e, "One", "one@test.dev")
code, me := e.do(t, "GET", "/api/auth/me", admin, nil)
if code != http.StatusOK {
t.Fatal(code)
}
adminID := int64(me["user"].(map[string]any)["id"].(float64))
code, body := e.do(t, "POST", "/api/chats/broadcast", admin, map[string]any{
"body": "فقط برای تو", "userIds": []int64{sid},
})
if code != http.StatusOK || body["sent"].(float64) != 1 {
t.Fatalf("broadcast: %d %v", code, body)
}
code, thread := e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != http.StatusOK {
t.Fatalf("thread: %d %v", code, thread)
}
found := false
for _, raw := range thread["messages"].([]any) {
if raw.(map[string]any)["body"] == "فقط برای تو" {
found = true
}
}
if !found {
t.Fatal("targeted broadcast missing")
}
}
func TestChatPinAndBotReply(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
student, sid := register(t, e, "Chatty", "chatty@test.dev")
code, _ := e.do(t, "PUT", "/api/chats/"+itoa(sid)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": true, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("prefs: %d", code)
}
code, conv := e.do(t, "GET", "/api/chats/conversations?page=1&pageSize=20", admin, nil)
if code != http.StatusOK {
t.Fatalf("conversations: %d %v", code, conv)
}
code, bot := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "ربات آزمون"})
if code != http.StatusCreated {
t.Fatalf("bot: %d %v", code, bot)
}
token := bot["token"].(string)
botID := int64(bot["botId"].(float64))
code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": token}, map[string]any{
"userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}},
})
if code != http.StatusOK {
t.Fatalf("bot send: %d %v", code, sent)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(botID)+"/messages", student, map[string]any{"body": "جواب هنرجو"})
if code != http.StatusOK && code != http.StatusCreated {
t.Fatalf("reply: %d", code)
}
code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": token}, nil)
if code != http.StatusOK || len(updates["updates"].([]any)) == 0 {
t.Fatalf("updates: %d %v", code, updates)
}
}
func TestLastSeenAndPinnedReorder(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, alpha := register(t, e, "آلفا", "alpha-pin@test.dev")
_, beta := register(t, e, "بتا", "beta-pin@test.dev")
for _, id := range []int64{alpha, beta} {
code, body := e.do(t, "POST", "/api/chats/"+itoa(id)+"/messages", admin, map[string]any{"body": "سلام"})
if code != http.StatusCreated && code != http.StatusOK {
t.Fatalf("message %d: %d %v", id, code, body)
}
}
code, _ := e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{
"pinnedRank": 0, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("pin alpha: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("pin beta: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(alpha)+"/prefs", admin, map[string]any{
"pinnedRank": 1, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("swap alpha: %d", code)
}
code, _ = e.do(t, "PUT", "/api/chats/"+itoa(beta)+"/prefs", admin, map[string]any{
"pinnedRank": 0, "muted": false, "archived": false,
})
if code != http.StatusOK {
t.Fatalf("swap beta: %d", code)
}
code, conv := e.do(t, "GET", "/api/chats/conversations", admin, nil)
if code != http.StatusOK {
t.Fatalf("conversations: %d %v", code, conv)
}
var pinned []float64
for _, raw := range conv["conversations"].([]any) {
row := raw.(map[string]any)
partner := row["partner"].(map[string]any)
id := partner["id"].(float64)
if id != float64(alpha) && id != float64(beta) {
continue
}
rank, ok := row["pinnedRank"].(float64)
if !ok {
t.Fatalf("missing pinnedRank for %v", id)
}
pinned = append(pinned, id)
if id == float64(beta) && rank != 0 {
t.Fatalf("beta rank: %v", rank)
}
if id == float64(alpha) && rank != 1 {
t.Fatalf("alpha rank: %v", rank)
}
}
if len(pinned) != 2 || pinned[0] != float64(beta) {
t.Fatalf("pin order: %v", pinned)
}
seenAt := time.Now().Add(-2 * time.Hour).UTC()
if _, err := e.pool.Exec(context.Background(), `UPDATE users SET last_seen_at=$2 WHERE id=$1`, alpha, seenAt); err != nil {
t.Fatalf("last seen: %v", err)
}
code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, people)
}
var found bool
for _, raw := range people["mentors"].([]any) {
person := raw.(map[string]any)
if person["id"].(float64) != float64(alpha) {
continue
}
found = true
stamp, _ := person["lastSeen"].(string)
if stamp == "" {
t.Fatalf("lastSeen missing: %v", person)
}
parsed, err := time.Parse(time.RFC3339, stamp)
if err != nil {
parsed, err = time.Parse(time.RFC3339Nano, stamp)
}
if err != nil || parsed.Sub(seenAt).Abs() > time.Minute {
t.Fatalf("lastSeen %q: %v", stamp, err)
}
}
if !found {
t.Fatal("alpha missing from mentors")
}
}
func TestBotAPIKeys(t *testing.T) {
e := setup(t)
admin := login(t, e, bootstrapAdminEmail)
_, sid := register(t, e, "BotPeer", "bot-peer@test.dev")
code, created := e.do(t, "POST", "/api/admin/bots", admin, map[string]any{"name": "تست"})
if code != http.StatusCreated {
t.Fatalf("create: %d", code)
}
secret, _ := created["token"].(string)
if len(secret) < 32 {
t.Fatal("create did not return a secret")
}
if created["botId"] == nil {
t.Fatal("botId missing")
}
botUserID := created["botId"].(float64)
code, people := e.do(t, "GET", "/api/mentors?page=1&pageSize=50", admin, nil)
if code != http.StatusOK {
t.Fatalf("mentors: %d %v", code, people["error"])
}
for _, raw := range people["mentors"].([]any) {
person := raw.(map[string]any)
if person["id"].(float64) == botUserID {
t.Fatal("bot appeared in the mentor directory")
}
}
code, list := e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
if code != http.StatusOK {
t.Fatalf("list: %d", code)
}
if list["pageSize"] != float64(8) {
t.Fatalf("pageSize: %v", list["pageSize"])
}
assertBotListHidesSecret(t, list, secret)
item := findBotItem(t, list, "تست")
createdAt, _ := item["createdAt"].(string)
if item["active"] != true || item["requestCount"] != float64(0) || createdAt == "" {
t.Fatalf("row shape: active=%v count=%v hasCreated=%v", item["active"], item["requestCount"], createdAt != "")
}
id := int64(item["id"].(float64))
code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": "not-a-real-token"}, map[string]any{
"userId": sid, "text": "نباید بشمرد",
})
if code != http.StatusUnauthorized {
t.Fatalf("bad token: %d", code)
}
code, sent := e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{
"userId": sid, "text": "سلام از ربات", "buttons": []any{map[string]string{"text": "باشه"}},
})
if code != http.StatusOK {
t.Fatalf("send: %d %v", code, sent["error"])
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
if code != http.StatusOK {
t.Fatalf("list after send: %d", code)
}
assertBotListHidesSecret(t, list, secret)
item = findBotItem(t, list, "تست")
if item["requestCount"] != float64(1) {
t.Fatalf("requestCount after send: %v", item["requestCount"])
}
code, updates := e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil)
if code != http.StatusOK {
t.Fatalf("updates: %d %v", code, updates["error"])
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
item = findBotItem(t, list, "تست")
if item["requestCount"] != float64(2) {
t.Fatalf("requestCount after updates: %v", item["requestCount"])
}
code, stopped := e.do(t, "PUT", "/api/admin/bots/"+itoa(id), admin, map[string]any{"active": false})
if code != http.StatusOK || stopped["active"] != false {
t.Fatalf("stop: %d %v", code, stopped["error"])
}
code, _ = e.doHeaders(t, "POST", "/api/bot/send", "", map[string]string{"X-Bot-Token": secret}, map[string]any{
"userId": sid, "text": "بعد از توقف",
})
if code != http.StatusUnauthorized {
t.Fatalf("stopped send: %d", code)
}
code, _ = e.doHeaders(t, "GET", "/api/bot/updates", "", map[string]string{"X-Bot-Token": secret}, nil)
if code != http.StatusUnauthorized {
t.Fatalf("stopped updates: %d", code)
}
code, list = e.do(t, "GET", "/api/admin/bots?page=1&pageSize=8", admin, nil)
assertBotListHidesSecret(t, list, secret)
item = findBotItem(t, list, "تست")
if item["active"] != false || item["requestCount"] != float64(2) {
t.Fatalf("after stop: active=%v count=%v", item["active"], item["requestCount"])
}
}
func findBotItem(t *testing.T, list map[string]any, name string) map[string]any {
t.Helper()
items, _ := list["items"].([]any)
for _, raw := range items {
row, _ := raw.(map[string]any)
if row["name"] == name {
return row
}
}
t.Fatalf("missing bot %s", name)
return nil
}
func assertBotListHidesSecret(t *testing.T, list map[string]any, secret string) {
t.Helper()
raw, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
if secret != "" && strings.Contains(string(raw), secret) {
t.Fatal("list included the bot secret")
}
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
for k, child := range x {
switch k {
case "token", "tokenHash", "token_hash", "secret":
t.Fatalf("unexpected field %s", k)
}
walk(child)
}
case []any:
for _, child := range x {
walk(child)
}
}
}
walk(list)
}
+41 -16
View File
@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"errors"
"net/http"
@@ -22,21 +23,45 @@ func (s *Server) handlePublicProfile(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "بارگذاری پروفایل ممکن نشد")
return
}
u = s.settleAccount(r.Context(), u)
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,
},
})
me := currentUser(r)
extras, err := s.store.GetProfileExtras(r.Context(), u.ID)
if err != nil {
extras = store.ProfileExtras{}
}
full := me.ID == u.ID || me.Role.IsStaff()
if !full && !extras.Public {
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": u.ID, "name": u.Name, "role": u.Role,
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"private": true, "isFrozen": u.IsFrozen, "isClosed": u.IsClosed,
},
})
return
}
view := map[string]any{
"id": u.ID, "name": u.Name, "username": u.Username, "role": u.Role,
"avatarVariant": u.AvatarVariant, "avatarPalette": u.AvatarPalette, "avatarPhoto": u.AvatarPhoto,
"private": false, "public": extras.Public,
"createdAt": u.CreatedAt,
"isFrozen": u.IsFrozen, "isClosed": u.IsClosed,
}
if full || extras.ShowStats {
view["xp"] = u.XP
view["hearts"] = u.Hearts
view["streakCurrent"] = u.StreakCurrent
view["streakLongest"] = u.StreakLongest
view["isLocked"] = u.IsLocked
}
if full || extras.Public {
view["banner"] = s.signExistingMediaURL(extras.Banner)
links := extras.SocialLinks
if len(links) == 0 {
links = []byte(`[]`)
}
view["socialLinks"] = json.RawMessage(links)
}
writeJSON(w, http.StatusOK, map[string]any{"user": view})
}
+8 -8
View File
@@ -77,6 +77,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error())
return
}
if !pg.PassedQuiz && !u.Role.IsStaff() && u.Hearts <= 0 {
writeErr(w, http.StatusForbidden, "جان‌ها تمام شده است. تا بازگشت یک جان صبر کنید.")
return
}
var req quizSubmitRequest
if err := bodyJSON(r, &req); err != nil {
@@ -155,15 +159,11 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
}
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)
}()
_ = s.notify.Notify(r.Context(), 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)
}()
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.
+28
View File
@@ -152,6 +152,34 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("DELETE /api/admin/challenges/{id}", admin(s.handleAdminDeleteChallenge))
mux.HandleFunc("POST /api/admin/announce", admin(s.handleAdminAnnounce))
mux.HandleFunc("GET /api/admin/announcements", admin(s.handleAdminListAnnouncements))
mux.HandleFunc("PUT /api/admin/announcements/{id}", admin(s.handleAdminUpdateAnnouncement))
mux.HandleFunc("DELETE /api/admin/announcements/{id}", admin(s.handleAdminDeleteAnnouncement))
mux.HandleFunc("GET /api/admin/bots", admin(s.handleAdminListBots))
mux.HandleFunc("POST /api/admin/bots", admin(s.handleCreateBot))
mux.HandleFunc("PUT /api/admin/bots/{id}", admin(s.handleAdminSetBotActive))
mux.HandleFunc("GET /api/announcements/pinned", s.requireAuth(s.handlePinnedAnnouncement))
mux.HandleFunc("PUT /api/chats/{partner}/prefs", s.requireAuth(s.handleChatPrefs))
mux.HandleFunc("POST /api/chats/mute-all", s.requireAuth(s.handleChatMuteAll))
mux.HandleFunc("GET /api/chats/{partner}/peek", s.requireAuth(s.handleChatPeek))
mux.HandleFunc("POST /api/chats/{partner}/unread", s.requireAuth(s.handleChatUnread))
mux.HandleFunc("DELETE /api/chats/{partner}", s.requireAuth(s.handleChatDeleteBoth))
mux.HandleFunc("POST /api/chats/broadcast", s.requireAuth(s.handleChatBroadcast))
mux.HandleFunc("GET /api/me/saved", s.requireAuth(s.handleListSaved))
mux.HandleFunc("POST /api/me/saved", s.requireAuth(s.handleSaveMessage))
mux.HandleFunc("GET /api/me/profile", s.requireAuth(s.handleGetMeProfile))
mux.HandleFunc("PUT /api/me/profile", s.requireAuth(s.handlePutMeProfile))
mux.HandleFunc("PUT /api/me/account", s.requireAuth(s.handleUpdateAccount))
mux.HandleFunc("POST /api/me/freeze", s.requireAuth(s.handleFreezeAccount))
mux.HandleFunc("POST /api/me/unfreeze", s.requireAuth(s.handleUnfreezeAccount))
mux.HandleFunc("PUT /api/me/username", s.requireAuth(s.handleUpdateUsername))
mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements))
mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity))
mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport))
mux.HandleFunc("GET /api/search", s.requireAuth(s.handleSearch))
mux.HandleFunc("GET /api/meta", s.handleMeta)
mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
return mux
}
+7
View File
@@ -1,10 +1,12 @@
package api
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
@@ -31,6 +33,11 @@ type Server struct {
func NewServer(cfg *config.Config, st *store.Store, rdb *redis.Client, stg storage.Storage) *Server {
hub := NewHub(rdb)
hub.SetSeen(func(userID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = st.TouchLastSeen(ctx, userID)
})
notifier := service.NewNotifier(st, hub)
trustedProxyConfig = cfg
return &Server{
+86 -15
View File
@@ -9,6 +9,7 @@ import (
"unicode/utf8"
"pargar/backend/internal/observability"
"pargar/backend/internal/store"
)
func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) {
@@ -41,12 +42,66 @@ func (s *Server) loadSponsors(ctx context.Context) []map[string]string {
return out
}
func (s *Server) loadSocials(ctx context.Context) []map[string]string {
raw, err := s.store.GetSetting(ctx, "social_links")
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"])
url := strings.TrimSpace(item["url"])
if name == "" || url == "" {
continue
}
out = append(out, map[string]string{"name": name, "url": url})
}
return out
}
func validateSocialLinks(v string) string {
if v == "" {
return ""
}
var tmp []map[string]string
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
return "فرمت شبکه‌ها نامعتبر است"
}
if len(tmp) > 8 {
return "حداکثر ۸ شبکه مجاز است"
}
for _, item := range tmp {
name := strings.TrimSpace(item["name"])
url := strings.TrimSpace(item["url"])
if name == "" || url == "" {
return "نام و لینک شبکه الزامی است"
}
if utf8.RuneCountInString(name) > 40 {
return "نام شبکه خیلی طولانی است"
}
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return "لینک شبکه باید با http شروع شود"
}
if utf8.RuneCountInString(url) > 500 {
return "لینک شبکه خیلی طولانی است"
}
}
return ""
}
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",
"social_links",
"hearts_max",
"hearts_regen_minutes",
"hearts_lock_on_empty",
"registration_enabled",
"registration_require_whitelist",
}
@@ -58,15 +113,17 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
out[k] = ""
}
}
if strings.TrimSpace(out["donation_note"]) == "" {
out["donation_note"] = s.cfg.DonationNote
if strings.TrimSpace(out["social_links"]) == "" {
out["social_links"] = "[]"
}
if strings.TrimSpace(out["donation_enabled"]) == "" {
if s.cfg.DonationEnabled {
out["donation_enabled"] = "true"
} else {
out["donation_enabled"] = "false"
}
if strings.TrimSpace(out["hearts_max"]) == "" {
out["hearts_max"] = "5"
}
if strings.TrimSpace(out["hearts_regen_minutes"]) == "" {
out["hearts_regen_minutes"] = "240"
}
if strings.TrimSpace(out["hearts_lock_on_empty"]) == "" {
out["hearts_lock_on_empty"] = "false"
}
if strings.TrimSpace(out["physical_cert_enabled"]) == "" {
if s.cfg.PhysicalCertEnabled {
@@ -99,14 +156,16 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
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,
"social_links": true,
"hearts_max": true,
"hearts_regen_minutes": true,
"hearts_lock_on_empty": true,
}
for k, v := range req.Settings {
if !allowed[k] {
@@ -114,7 +173,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
}
v = strings.TrimSpace(v)
switch k {
case "donation_enabled", "physical_cert_enabled", "registration_enabled", "registration_require_whitelist":
case "physical_cert_enabled", "registration_enabled", "registration_require_whitelist", "hearts_lock_on_empty":
if v == "1" || strings.EqualFold(v, "true") || v == "yes" {
v = "true"
} else {
@@ -162,9 +221,21 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
return
}
}
case "donation_note":
if utf8.RuneCountInString(v) > 2000 {
writeErr(w, http.StatusBadRequest, "یادداشت دونیت خیلی طولانی است")
case "social_links":
if msg := validateSocialLinks(v); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
case "hearts_max":
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > store.MaxHearts {
writeErr(w, http.StatusBadRequest, "سقف قلب باید بین ۱ تا ۵ باشد")
return
}
case "hearts_regen_minutes":
n, err := strconv.Atoi(v)
if err != nil || n < 5 || n > 24*60 {
writeErr(w, http.StatusBadRequest, "فاصله بازگشت قلب باید بین ۵ تا ۱۴۴۰ دقیقه باشد")
return
}
}
+167 -16
View File
@@ -80,41 +80,88 @@ func TestChatReplaceAttachment(t *testing.T) {
msgID := int64(msg["id"].(float64))
oldPath := mediaPath(msg["attachment"].(map[string]any)["url"])
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن صدا",
})
if code != 200 {
t.Fatalf("caption: %d %v", code, body)
}
captioned := body["message"].(map[string]any)
if captioned["body"] != "کپشن صدا" || captioned["editedAt"] == nil {
t.Fatalf("caption edit: %v", captioned)
}
if mediaPath(captioned["attachment"].(map[string]any)["url"]) != oldPath {
t.Fatalf("caption edit replaced the file: %v", captioned)
}
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "",
})
if code != 200 {
t.Fatalf("clear caption: %d %v", code, body)
}
cleared := body["message"].(map[string]any)
if cleared["body"] != "" || mediaPath(cleared["attachment"].(map[string]any)["url"]) != oldPath {
t.Fatalf("empty caption should keep the file: %v", cleared)
}
png[8] = 0x01
code, up = uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload extra: %d %v", code, up)
}
extra := up["attachment"].(map[string]any)
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن صدا",
"attachment": extra,
})
if code != 200 {
t.Fatalf("append: %d %v", code, body)
}
appended := body["message"].(map[string]any)
appendedAtts := appended["attachments"].([]any)
if len(appendedAtts) != 2 || mediaPath(appendedAtts[0].(map[string]any)["url"]) != oldPath {
t.Fatalf("append wiped the original: %v", appended)
}
if mediaPath(appendedAtts[1].(map[string]any)["url"]) == "" || mediaPath(appendedAtts[1].(map[string]any)["url"]) == oldPath {
t.Fatalf("appended file: %v", appendedAtts)
}
png[8] = 0x02
code, up = uploadChatFile(t, e, student, "three.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,
"body": "کپشن صدا",
"replacements": []any{map[string]any{"index": 0, "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)
editedAtts := edited["attachments"].([]any)
newPath := mediaPath(editedAtts[0].(map[string]any)["url"])
if edited["editedAt"] == nil || len(editedAtts) != 2 || newPath == "" || newPath == oldPath {
t.Fatalf("expected only the first file to be replaced, got %v", edited)
}
if edited["attachment"].(map[string]any)["type"] != "image" {
t.Fatalf("type: %v", edited["attachment"])
if mediaPath(editedAtts[1].(map[string]any)["url"]) != mediaPath(extra["url"]) {
t.Fatalf("replace dropped the added file: %v", editedAtts)
}
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,
"body": "کپشن صدا",
"replacements": []any{map[string]any{"index": 0, "attachment": wrong}},
})
if code != 404 {
t.Fatalf("different kind should miss: %d", code)
if code != 400 {
t.Fatalf("different kind should be rejected: %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},
"body": "",
"replacements": []any{map[string]any{"index": 0, "attachment": map[string]any{"type": "file", "url": next["url"], "name": "x.pdf", "size": 1}}},
})
if code != 400 {
t.Fatalf("file replace: %d", code)
@@ -124,8 +171,8 @@ func TestChatReplaceAttachment(t *testing.T) {
"body": "",
"attachment": next,
})
if code != 400 {
t.Fatalf("partner replace: %d", code)
if code != 404 {
t.Fatalf("partner edit should be rejected: %d", code)
}
}
@@ -179,6 +226,110 @@ func TestChatReadMarksOnlyIncoming(t *testing.T) {
}
}
func TestChatSendMultipleAttachments(t *testing.T) {
e := setup(t)
student, _ := register(t, e, "Album Student", "album-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))
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}
code, up1 := uploadChatFile(t, e, student, "one.png", png)
if code != 201 {
t.Fatalf("upload one: %d %v", code, up1)
}
png[8] = 0x01
code, up2 := uploadChatFile(t, e, student, "two.png", png)
if code != 201 {
t.Fatalf("upload two: %d %v", code, up2)
}
a := up1["attachment"].(map[string]any)
b := up2["attachment"].(map[string]any)
code, body := e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"body": "کپشن مشترک",
"attachments": []any{a, b},
})
if code != 201 {
t.Fatalf("send album: %d %v", code, body)
}
msg := body["message"].(map[string]any)
if msg["body"] != "کپشن مشترک" {
t.Fatalf("caption: %v", msg["body"])
}
atts := msg["attachments"].([]any)
if len(atts) != 2 {
t.Fatalf("attachments: %v", msg["attachments"])
}
if mediaPath(atts[0].(map[string]any)["url"]) == "" || mediaPath(atts[0].(map[string]any)["url"]) == mediaPath(atts[1].(map[string]any)["url"]) {
t.Fatalf("photo urls: %v", atts)
}
if mediaPath(msg["attachment"].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) {
t.Fatalf("first attachment should mirror the album: %v", msg["attachment"])
}
msgID := int64(msg["id"].(float64))
code, body = e.do(t, "GET", "/api/chats/"+itoa(adminID)+"/messages", student, nil)
if code != 200 {
t.Fatalf("list: %d %v", code, body)
}
listed := chatMsg(t, body, msgID)
if listed["readAt"] != nil {
t.Fatalf("own album was marked seen: %v", listed)
}
listedAtts := listed["attachments"].([]any)
if len(listedAtts) != 2 || listed["body"] != "کپشن مشترک" {
t.Fatalf("listed album: %v", listed)
}
code, body = e.do(t, "PUT", "/api/chats/"+itoa(adminID)+"/messages/"+itoa(msgID), student, map[string]any{
"body": "کپشن ویرایش‌شده",
})
if code != 200 {
t.Fatalf("album caption: %d %v", code, body)
}
albumEdit := body["message"].(map[string]any)
albumAtts := albumEdit["attachments"].([]any)
if albumEdit["body"] != "کپشن ویرایش‌شده" || len(albumAtts) != 2 {
t.Fatalf("album caption edit: %v", albumEdit)
}
if mediaPath(albumAtts[0].(map[string]any)["url"]) != mediaPath(atts[0].(map[string]any)["url"]) ||
mediaPath(albumAtts[1].(map[string]any)["url"]) != mediaPath(atts[1].(map[string]any)["url"]) {
t.Fatalf("album caption edit changed photos: %v", albumAtts)
}
code, up3 := uploadChatFile(t, e, student, "notes.txt", []byte("hello"))
if code != 201 {
t.Fatalf("upload txt: %d %v", code, up3)
}
code, up4 := uploadChatFile(t, e, student, "more.txt", []byte("world"))
if code != 201 {
t.Fatalf("upload txt 2: %d %v", code, up4)
}
code, body = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{
"attachments": []any{up3["attachment"], up4["attachment"]},
})
if code != 201 {
t.Fatalf("send files: %d %v", code, body)
}
files := body["message"].(map[string]any)["attachments"].([]any)
if len(files) != 2 || files[0].(map[string]any)["type"] != "file" || files[1].(map[string]any)["name"] != "more.txt" {
t.Fatalf("files: %v", files)
}
many := make([]any, 0, 11)
for i := 0; i < 11; i++ {
many = append(many, a)
}
code, _ = e.do(t, "POST", "/api/chats/"+itoa(adminID)+"/messages", student, map[string]any{"attachments": many})
if code != 400 {
t.Fatalf("too many: %d", code)
}
}
func chatMsg(t *testing.T, body map[string]any, id int64) map[string]any {
t.Helper()
for _, raw := range body["messages"].([]any) {
+31 -3
View File
@@ -16,9 +16,10 @@ 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
mu sync.RWMutex
conns map[int64]map[*Client]struct{}
rdb *redis.Client
onSeen func(userID int64)
}
type Client struct {
@@ -84,6 +85,26 @@ func (h *Hub) Online(userID int64) bool {
return len(h.conns[userID]) > 0
}
// SetSeen records a callback invoked when a user connects, stays connected, or disconnects.
func (h *Hub) SetSeen(fn func(userID int64)) {
h.mu.Lock()
h.onSeen = fn
h.mu.Unlock()
}
func (h *Hub) noteSeen(userID int64) {
if userID == 0 {
return
}
h.mu.RLock()
fn := h.onSeen
h.mu.RUnlock()
if fn == nil {
return
}
go fn(userID)
}
// 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)}
@@ -93,23 +114,29 @@ func (h *Hub) Register(userID int64, conn *websocket.Conn) *Client {
}
h.conns[userID][c] = struct{}{}
h.mu.Unlock()
h.noteSeen(userID)
go c.writePump()
go c.readPump()
return c
}
func (h *Hub) Unregister(c *Client) {
offline := false
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)
offline = true
}
}
}
h.mu.Unlock()
close(c.send)
if offline {
h.noteSeen(c.user)
}
}
// close unregisters the client and closes its connection exactly once,
@@ -148,6 +175,7 @@ func (c *Client) writePump() {
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
c.hub.noteSeen(c.user)
}
}
}