feat: add cards templete

This commit is contained in:
2026-06-24 10:47:54 +03:30
parent 60494946d6
commit b1c7bfdd06
124 changed files with 422 additions and 30 deletions
+134 -11
View File
@@ -3,12 +3,18 @@
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"
@@ -20,14 +26,15 @@ import (
var tplFS embed.FS
type Handler struct {
db *sql.DB
eco *economy.Service
tpl *template.Template
db *sql.DB
eco *economy.Service
tpl *template.Template
cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند)
}
func New(db *sql.DB, eco *economy.Service) *Handler {
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}
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir}
}
// Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند.
@@ -38,10 +45,12 @@ func (h *Handler) Routes(user, pass string) http.Handler {
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)
return r
}
@@ -66,6 +75,8 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
"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)
@@ -87,8 +98,8 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
"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,sort,enabled FROM table_tiers ORDER BY sort`,
"id", "title", "hands", "entry", "prize", "xp", "trophy", "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)
@@ -127,8 +138,8 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) {
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=?,sort=?,enabled=? WHERE id=?`
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id}
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
@@ -179,8 +190,8 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
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,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)`
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en}
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
@@ -194,6 +205,109 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
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/<id>/) استخراج می‌کند تا بدونِ کامپایلِ
// دوباره از همان مسیرِ /cards/<id>/... سرو شود.
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 {
@@ -218,6 +332,15 @@ func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
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) {