- leaderboard: batch-fetch users (GetUsersByIDs), deterministic tie-break by user_id - leaderboard: tier system (champion/gold/silver/bronze) based on rank percentile - store: HasCompletedCurriculum requires cert.Status == "issued"; pending does not satisfy - certificate: issueCertificateIfEligible creates pending row; admin approves via handleAdminIssueCertificate - certificate: handleRequestPhysicalCertificate returns 410 Gone (feature disabled) - settings: contributors CRUD (loadContributors, validateContributors, handleAdminContributorAvatarUpload) - community: expose contributors in /api/community response - rate_limit: separate DevMode bypass from redis-nil check in allowWSHandshake - router: remove /api/search route; fix missing tab indent on /api/meta line - migration 0032: contributors app_setting with seed entry
387 lines
12 KiB
Go
387 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"pargar/backend/internal/observability"
|
|
"pargar/backend/internal/store"
|
|
)
|
|
|
|
func (s *Server) handleGetSponsors(w http.ResponseWriter, r *http.Request) {
|
|
sponsors := s.loadSponsors(r.Context())
|
|
writeJSON(w, http.StatusOK, map[string]any{"sponsors": sponsors})
|
|
}
|
|
|
|
func (s *Server) loadSponsors(ctx context.Context) []map[string]string {
|
|
raw, err := s.store.GetSetting(ctx, "sponsors")
|
|
if err != nil || strings.TrimSpace(raw) == "" {
|
|
return []map[string]string{}
|
|
}
|
|
var list []map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
|
return []map[string]string{}
|
|
}
|
|
out := make([]map[string]string, 0, len(list))
|
|
for _, item := range list {
|
|
name := strings.TrimSpace(item["name"])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
out = append(out, map[string]string{
|
|
"name": name,
|
|
"url": strings.TrimSpace(item["url"]),
|
|
"blurb": strings.TrimSpace(item["blurb"]),
|
|
"logo": strings.TrimSpace(item["logo"]),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) 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) loadContributors(ctx context.Context) []map[string]any {
|
|
raw, err := s.store.GetSetting(ctx, "contributors")
|
|
if err != nil || strings.TrimSpace(raw) == "" {
|
|
return []map[string]any{}
|
|
}
|
|
var list []map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &list); err != nil {
|
|
return []map[string]any{}
|
|
}
|
|
out := make([]map[string]any, 0, len(list))
|
|
for _, item := range list {
|
|
name, _ := item["name"].(string)
|
|
if strings.TrimSpace(name) == "" {
|
|
continue
|
|
}
|
|
role, _ := item["role"].(string)
|
|
bio, _ := item["bio"].(string)
|
|
url, _ := item["url"].(string)
|
|
avatar, _ := item["avatar"].(string)
|
|
contributions, _ := item["contributions"].([]any)
|
|
cats := make([]string, 0, len(contributions))
|
|
for _, c := range contributions {
|
|
if s, ok := c.(string); ok && s != "" {
|
|
cats = append(cats, s)
|
|
}
|
|
}
|
|
out = append(out, map[string]any{
|
|
"name": strings.TrimSpace(name),
|
|
"role": strings.TrimSpace(role),
|
|
"bio": strings.TrimSpace(bio),
|
|
"url": strings.TrimSpace(url),
|
|
"avatar": strings.TrimSpace(avatar),
|
|
"contributions": cats,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateContributors(v string) string {
|
|
if v == "" || v == "[]" {
|
|
return ""
|
|
}
|
|
var tmp []map[string]any
|
|
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
|
|
return "فرمت مشارکتکنندگان نامعتبر است"
|
|
}
|
|
if len(tmp) > 50 {
|
|
return "حداکثر ۵۰ مشارکتکننده مجاز است"
|
|
}
|
|
for _, item := range tmp {
|
|
name, _ := item["name"].(string)
|
|
if strings.TrimSpace(name) == "" {
|
|
return "نام مشارکتکننده الزامی است"
|
|
}
|
|
if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 {
|
|
return "نام مشارکتکننده خیلی طولانی است"
|
|
}
|
|
if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 {
|
|
return "نقش مشارکتکننده خیلی طولانی است"
|
|
}
|
|
if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 {
|
|
return "بیوگرافی خیلی طولانی است"
|
|
}
|
|
if url, _ := item["url"].(string); url != "" {
|
|
u := strings.TrimSpace(url)
|
|
if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") {
|
|
return "لینک پروفایل باید با http شروع شود"
|
|
}
|
|
if utf8.RuneCountInString(u) > 500 {
|
|
return "لینک پروفایل خیلی طولانی است"
|
|
}
|
|
}
|
|
if contributions, ok := item["contributions"].([]any); ok {
|
|
if len(contributions) > 20 {
|
|
return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است"
|
|
}
|
|
for _, c := range contributions {
|
|
s, ok := c.(string)
|
|
if !ok {
|
|
return "دسته مشارکت باید رشته باشد"
|
|
}
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return "دسته مشارکت نمیتواند خالی باشد"
|
|
}
|
|
if utf8.RuneCountInString(s) > 50 {
|
|
return "دسته مشارکت خیلی طولانی است"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
|
const maxSize = 6 << 20
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20)
|
|
if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است")
|
|
return
|
|
}
|
|
file, h, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
if h.Size > maxSize {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد")
|
|
return
|
|
}
|
|
accepted, err := acceptAvatarImage(h, file)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext)
|
|
rawURL, err := s.storage.Save(key, file, h.Size)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)})
|
|
}
|
|
|
|
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
keys := []string{
|
|
"physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days",
|
|
"sponsors",
|
|
"social_links",
|
|
"contributors",
|
|
"hearts_max",
|
|
"hearts_regen_minutes",
|
|
"hearts_lock_on_empty",
|
|
"registration_enabled",
|
|
"registration_require_whitelist",
|
|
}
|
|
out := map[string]string{}
|
|
for _, k := range keys {
|
|
if v, err := s.store.GetSetting(ctx, k); err == nil {
|
|
out[k] = v
|
|
} else {
|
|
out[k] = ""
|
|
}
|
|
}
|
|
if strings.TrimSpace(out["social_links"]) == "" {
|
|
out["social_links"] = "[]"
|
|
}
|
|
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 {
|
|
out["physical_cert_enabled"] = "true"
|
|
} else {
|
|
out["physical_cert_enabled"] = "false"
|
|
}
|
|
}
|
|
if strings.TrimSpace(out["physical_cert_price_irr"]) == "" {
|
|
out["physical_cert_price_irr"] = strconv.Itoa(s.cfg.PhysicalCertPriceIRR)
|
|
}
|
|
if strings.TrimSpace(out["physical_cert_window_days"]) == "" {
|
|
out["physical_cert_window_days"] = strconv.Itoa(s.cfg.PhysicalCertWindowDays)
|
|
}
|
|
if strings.TrimSpace(out["registration_enabled"]) == "" {
|
|
out["registration_enabled"] = "false"
|
|
}
|
|
if strings.TrimSpace(out["registration_require_whitelist"]) == "" {
|
|
out["registration_require_whitelist"] = "true"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"settings": out})
|
|
}
|
|
|
|
func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Settings map[string]string `json:"settings"`
|
|
}
|
|
if err := bodyJSON(r, &req); err != nil || req.Settings == nil {
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
return
|
|
}
|
|
allowed := map[string]bool{
|
|
"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,
|
|
"contributors": true,
|
|
"hearts_max": true,
|
|
"hearts_regen_minutes": true,
|
|
"hearts_lock_on_empty": true,
|
|
}
|
|
for k, v := range req.Settings {
|
|
if !allowed[k] {
|
|
continue
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
switch k {
|
|
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 {
|
|
v = "false"
|
|
}
|
|
case "physical_cert_price_irr", "physical_cert_window_days":
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
writeErr(w, http.StatusBadRequest, "مقدار عددی نامعتبر است: "+k)
|
|
return
|
|
}
|
|
if k == "physical_cert_window_days" && (n < 1 || n > 3650) {
|
|
writeErr(w, http.StatusBadRequest, "مهلت گواهینامه باید بین ۱ تا ۳۶۵۰ روز باشد")
|
|
return
|
|
}
|
|
case "sponsors":
|
|
if v == "" {
|
|
v = "[]"
|
|
}
|
|
var tmp []map[string]string
|
|
if err := json.Unmarshal([]byte(v), &tmp); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "فرمت اسپانسرها نامعتبر است")
|
|
return
|
|
}
|
|
if len(tmp) > 20 {
|
|
writeErr(w, http.StatusBadRequest, "حداکثر ۲۰ اسپانسر مجاز است")
|
|
return
|
|
}
|
|
for _, item := range tmp {
|
|
name := strings.TrimSpace(item["name"])
|
|
if name == "" {
|
|
writeErr(w, http.StatusBadRequest, "نام اسپانسر الزامی است")
|
|
return
|
|
}
|
|
if utf8.RuneCountInString(name) > 100 {
|
|
writeErr(w, http.StatusBadRequest, "نام اسپانسر خیلی طولانی است")
|
|
return
|
|
}
|
|
if utf8.RuneCountInString(strings.TrimSpace(item["blurb"])) > 300 {
|
|
writeErr(w, http.StatusBadRequest, "توضیح اسپانسر خیلی طولانی است")
|
|
return
|
|
}
|
|
if utf8.RuneCountInString(strings.TrimSpace(item["url"])) > 500 {
|
|
writeErr(w, http.StatusBadRequest, "لینک اسپانسر خیلی طولانی است")
|
|
return
|
|
}
|
|
}
|
|
case "social_links":
|
|
if msg := validateSocialLinks(v); msg != "" {
|
|
writeErr(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
case "contributors":
|
|
if v == "" {
|
|
v = "[]"
|
|
}
|
|
if msg := validateContributors(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
|
|
}
|
|
}
|
|
if err := s.store.SetSetting(r.Context(), k, v); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "ذخیره تنظیمات ممکن نشد")
|
|
return
|
|
}
|
|
}
|
|
observability.Activity("settings.updated", "actor_id", currentUser(r).ID)
|
|
s.handleAdminGetSettings(w, r)
|
|
}
|