// پکیج admin یک پنل ادمینِ سرور‌سایدِ سبک (HTML) برای مدیریت فروشگاه و کاربران است. // همه‌ی صفحات پشت Basic Auth هستند و در همان باینری Go سرو می‌شوند (بدون پروسه‌ی جدا). package admin import ( "archive/zip" "context" "database/sql" "embed" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "hakemsho/internal/economy" ) //go:embed templates/*.html var tplFS embed.FS type Handler struct { db *sql.DB eco *economy.Service tpl *template.Template cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) } func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler { tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir} } // Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. func (h *Handler) Routes(user, pass string) http.Handler { r := chi.NewRouter() r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{user: pass})) r.Get("/", h.dashboard) r.Get("/shop", h.shop) r.Post("/shop/{kind}/add", h.addItem) r.Post("/shop/{kind}/delete", h.deleteItem) r.Post("/shop/card/{id}/upload", h.uploadDeck) r.Post("/shop/{kind}", h.updateItem) r.Get("/users", h.users) r.Post("/users/coins", h.adjustCoins) r.Post("/users/grant", h.grantToUser) r.Post("/season/reset", h.resetSeason) r.Get("/chat", h.chatAdmin) r.Post("/chat/pack/add", h.chatPackAdd) r.Post("/chat/pack/delete", h.chatPackDelete) r.Post("/chat/message/add", h.chatMessageAdd) r.Post("/chat/message/delete", h.chatMessageDelete) return r } func (h *Handler) render(w http.ResponseWriter, page string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.tpl.ExecuteTemplate(w, page, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } // --- داشبورد --- func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() count := func(q string) int64 { var n int64 _ = h.db.QueryRowContext(ctx, q).Scan(&n) return n } data := map[string]any{ "Users": count(`SELECT COUNT(*) FROM users`), "Purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`), "Games": count(`SELECT COUNT(*) FROM game_history`), "Coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`), "Season": h.eco.Season(ctx), "Saved": r.URL.Query().Get("saved") == "1", "Nav": "dashboard", } h.render(w, "dashboard.html", data) } // --- فروشگاه (ویرایش کاتالوگ) --- func (h *Handler) shop(w http.ResponseWriter, r *http.Request) { ctx := r.Context() data := map[string]any{ "Nav": "shop", "Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`, "id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"), "Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`, "id", "title", "tickets", "price_toman", "sort", "enabled"), "Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`, "id", "title", "price_coins", "sort", "enabled"), "Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`, "id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"), "VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`, "id", "title", "months", "price_toman", "sort", "enabled"), "Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`, "id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"), "Saved": r.URL.Query().Get("saved") == "1", } h.render(w, "shop.html", data) } // updateItem یک ردیفِ کاتالوگ را بر اساس kind به‌روزرسانی و کش را تازه می‌کند. func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } kind := chi.URLParam(r, "kind") id := r.FormValue("id") f := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } en := 0 if r.FormValue("enabled") == "on" { en = 1 } var q string var args []any switch kind { case "coin": q = `UPDATE coin_packages SET title=?,coins=?,vip_days=?,price_toman=?,bonus_pct=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en, id} case "ticket": q = `UPDATE ticket_packages SET title=?,tickets=?,price_toman=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en, id} case "card": q = `UPDATE card_skins SET title=?,price_coins=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("price_coins"), f("sort"), en, id} case "booster": q = `UPDATE boosters SET title=?,multiplier=?,hours=?,price_toman=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en, id} case "vip": q = `UPDATE vip_packages SET title=?,months=?,price_toman=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en, id} case "tier": q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,rank_reward=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en, id} default: http.Error(w, "unknown kind", http.StatusBadRequest) return } if _, err := h.db.ExecContext(r.Context(), q, args...); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = h.eco.LoadCatalog(r.Context()) // تازه‌سازی کشِ کاتالوگ http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } // addItem یک ردیفِ جدید به کاتالوگ اضافه می‌کند (افزودن محصول). func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } kind := chi.URLParam(r, "kind") id := r.FormValue("id") if id == "" { http.Error(w, "id required", http.StatusBadRequest) return } f := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } en := 0 if r.FormValue("enabled") == "on" { en = 1 } var q string var args []any switch kind { case "coin": q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en} case "ticket": q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en} case "card": q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en} case "booster": q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en} case "vip": q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en} case "tier": q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en} default: http.Error(w, "unknown kind", http.StatusBadRequest) return } if _, err := h.db.ExecContext(r.Context(), q, args...); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = h.eco.LoadCatalog(r.Context()) http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } // validCardFile نامِ کانونیِ خروجی را برای یک ورودیِ zip برمی‌گرداند (یا ok=false). // مجاز: back.(jpg|png) → "back.jpg" و <کد>.png → "<کدِ بزرگ>.png" // که <کد> = رتبه (A,2..10,J,Q,K) + خال (S,H,D,C)، مثل AS.png یا 10H.png. // خروجی همیشه «کدِ بزرگ + پسوندِ کوچک» است تا با درخواست‌های اپ روی فایل‌سیستمِ // حساس‌به‌حروف (لینوکس) دقیقاً مطابقت کند. func validCardFile(name string) (string, bool) { base := filepath.Base(name) low := strings.ToLower(base) if low == "back.jpg" || low == "back.png" { return "back.jpg", true // اپ پشتِ کارت را با نامِ back.jpg می‌خواهد } if !strings.HasSuffix(low, ".png") { return "", false } code := strings.ToUpper(base[:len(base)-len(".png")]) suits := "SHDC" if len(code) < 2 || !strings.Contains(suits, code[len(code)-1:]) { return "", false } ranks := map[string]bool{"A": true, "2": true, "3": true, "4": true, "5": true, "6": true, "7": true, "8": true, "9": true, "10": true, "J": true, "Q": true, "K": true} if !ranks[code[:len(code)-1]] { return "", false } return code + ".png", true } // uploadDeck یک فایلِ zip شاملِ تصاویرِ کارت را برای یک اسکین آپلود می‌کند و // در پوشه‌ی دیسکیِ ماندگار (cardsDir//) استخراج می‌کند تا بدونِ کامپایلِ // دوباره از همان مسیرِ /cards//... سرو شود. func (h *Handler) uploadDeck(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" || strings.ContainsAny(id, "/\\.") { http.Error(w, "invalid skin id", http.StatusBadRequest) return } if h.cardsDir == "" { http.Error(w, "CARDS_DIR تنظیم نشده است", http.StatusInternalServerError) return } if err := r.ParseMultipartForm(64 << 20); err != nil { // حداکثر ۶۴MB http.Error(w, "bad upload", http.StatusBadRequest) return } file, _, err := r.FormFile("deck") if err != nil { http.Error(w, "deck (zip) لازم است", http.StatusBadRequest) return } defer file.Close() buf, err := io.ReadAll(io.LimitReader(file, 64<<20)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } zr, err := zip.NewReader(strings.NewReader(string(buf)), int64(len(buf))) if err != nil { http.Error(w, "فایل zip معتبر نیست", http.StatusBadRequest) return } dir := filepath.Join(h.cardsDir, id) if err := os.MkdirAll(dir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } written := 0 for _, zf := range zr.File { if zf.FileInfo().IsDir() { continue } out, ok := validCardFile(zf.Name) if !ok { continue // فایل‌های نامربوط/ناقص نادیده گرفته می‌شوند } if err := extractZipEntry(zf, filepath.Join(dir, out)); err != nil { http.Error(w, fmt.Sprintf("استخراج %s: %v", zf.Name, err), http.StatusInternalServerError) return } written++ } if written == 0 { http.Error(w, "هیچ تصویرِ کارتِ معتبری در zip یافت نشد (مثل AS.png، 10H.png، back.jpg)", http.StatusBadRequest) return } http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } func extractZipEntry(zf *zip.File, dst string) error { rc, err := zf.Open() if err != nil { return err } defer rc.Close() f, err := os.Create(dst) if err != nil { return err } defer f.Close() _, err = io.Copy(f, io.LimitReader(rc, 16<<20)) // سقفِ هر فایل ۱۶MB return err } // deleteItem یک ردیفِ کاتالوگ را حذف می‌کند. func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } tables := map[string]string{ "coin": "coin_packages", "ticket": "ticket_packages", "card": "card_skins", "booster": "boosters", "vip": "vip_packages", "tier": "table_tiers", } tbl, ok := tables[chi.URLParam(r, "kind")] if !ok { http.Error(w, "unknown kind", http.StatusBadRequest) return } if _, err := h.db.ExecContext(r.Context(), "DELETE FROM "+tbl+" WHERE id = ?", r.FormValue("id")); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = h.eco.LoadCatalog(r.Context()) http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } // resetSeason امتیازِ رتبه‌ی همه را صفر کرده و فصلِ جدید را آغاز می‌کند. func (h *Handler) resetSeason(w http.ResponseWriter, r *http.Request) { if err := h.eco.ResetSeason(r.Context()); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/?saved=1", http.StatusSeeOther) } // --- کاربران --- func (h *Handler) users(w http.ResponseWriter, r *http.Request) { ctx := r.Context() q := r.URL.Query().Get("q") query := `SELECT id, mobile, coins, tickets, xp, trophies, is_admin FROM users` var args []any if q != "" { query += ` WHERE mobile LIKE ?` args = append(args, "%"+q+"%") } query += ` ORDER BY id DESC LIMIT 100` rows, err := h.db.QueryContext(ctx, query, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type userRow struct { ID int64 Mobile string Coins, Tickets, XP, Trophy int64 Level int IsAdmin bool } var list []userRow for rows.Next() { var u userRow var admin int if err := rows.Scan(&u.ID, &u.Mobile, &u.Coins, &u.Tickets, &u.XP, &u.Trophy, &admin); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } u.IsAdmin = admin == 1 u.Level, _, _ = economy.LevelInfo(u.XP) list = append(list, u) } h.render(w, "users.html", map[string]any{ "Nav": "users", "Users": list, "Q": q, "Saved": r.URL.Query().Get("saved") == "1", }) } // adjustCoins سکه‌ی یک کاربر را افزایش/کاهش می‌دهد (لاگ در wallet_tx). func (h *Handler) adjustCoins(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } userID, _ := strconv.ParseInt(r.FormValue("user_id"), 10, 64) amount, _ := strconv.ParseInt(r.FormValue("amount"), 10, 64) if userID > 0 && amount != 0 { _ = h.eco.Adjust(r.Context(), userID, economy.CurrencyCoin, amount, "admin_adjust", "") } http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther) } // grantToUser یک محصول (سکه/بلیط/VIP/کارت) را به کاربر اعطا می‌کند. func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } userID, _ := strconv.ParseInt(r.FormValue("user_id"), 10, 64) kind := r.FormValue("kind") value := r.FormValue("value") ctx := r.Context() if userID <= 0 { http.Redirect(w, r, "/admin/users", http.StatusSeeOther) return } var err error switch kind { case "coins": amount, _ := strconv.ParseInt(value, 10, 64) err = h.eco.Adjust(ctx, userID, economy.CurrencyCoin, amount, "admin_grant", "") case "tickets": amount, _ := strconv.ParseInt(value, 10, 64) err = h.eco.Adjust(ctx, userID, economy.CurrencyTicket, amount, "admin_grant", "") case "vip": days, _ := strconv.Atoi(value) err = h.eco.GrantVIPDays(ctx, userID, days) case "card": err = h.eco.GrantCard(ctx, userID, value) } if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther) } // rows یک کوئری را به []map[string]any تبدیل می‌کند (برای رندرِ عمومیِ جدول‌ها). // --- مدیریتِ بسته‌های چت و پیام‌ها --- func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() packs := h.rows(ctx, `SELECT id,title,kind,price_coins,vip,sort,enabled FROM chat_packs ORDER BY sort`, "id", "title", "kind", "price_coins", "vip", "sort", "enabled") // پیام‌های هر بسته را زیرِ همان بسته قرار می‌دهیم. for _, p := range packs { id, _ := p["id"].(string) p["messages"] = h.messagesOf(ctx, id) } h.render(w, "chat.html", map[string]any{ "Nav": "chat", "Packs": packs, "Saved": r.URL.Query().Get("saved") == "1", }) } func (h *Handler) messagesOf(ctx context.Context, packID string) []map[string]any { rs, err := h.db.QueryContext(ctx, `SELECT id,body,sort FROM chat_messages WHERE pack_id=? ORDER BY sort,id`, packID) if err != nil { return nil } defer rs.Close() var out []map[string]any for rs.Next() { var id, sort int var body string if err := rs.Scan(&id, &body, &sort); err != nil { return out } out = append(out, map[string]any{"id": id, "body": body, "sort": sort}) } return out } func (h *Handler) chatPackAdd(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() id := r.FormValue("id") if id == "" { http.Error(w, "id required", http.StatusBadRequest) return } num := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } vip := 0 if r.FormValue("vip") == "on" { vip = 1 } kind := r.FormValue("kind") if kind != "emoji" { kind = "text" } _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`, id, r.FormValue("title"), kind, num("price_coins"), vip, num("sort")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } func (h *Handler) chatPackDelete(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() id := r.FormValue("id") _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE pack_id=?`, id) _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_packs WHERE id=?`, id) http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } func (h *Handler) chatMessageAdd(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() pack := r.FormValue("pack_id") body := r.FormValue("body") if pack == "" || body == "" { http.Redirect(w, r, "/admin/chat", http.StatusSeeOther) return } sort, _ := strconv.Atoi(r.FormValue("sort")) _, err := h.db.ExecContext(r.Context(), `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, pack, body, sort) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() id, _ := strconv.Atoi(r.FormValue("id")) _, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE id=?`, id) http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { rs, err := h.db.QueryContext(ctx, query) if err != nil { return nil } defer rs.Close() var out []map[string]any for rs.Next() { vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rs.Scan(ptrs...); err != nil { return out } m := make(map[string]any, len(cols)) for i, c := range cols { m[c] = vals[i] } out = append(out, m) } return out }