feat: add characters
This commit is contained in:
+58
-2
@@ -33,11 +33,12 @@ type Handler struct {
|
||||
tpl *template.Template
|
||||
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
||||
carpetsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ فرش
|
||||
avatarsDir string // پوشهی دیسکیِ آپلودِ تصاویرِ شخصیتِ آواتار
|
||||
}
|
||||
|
||||
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler {
|
||||
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir, avatarsDir string) *Handler {
|
||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir}
|
||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir, avatarsDir: avatarsDir}
|
||||
}
|
||||
|
||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||
@@ -537,6 +538,61 @@ func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// avatarUpload یک تصویرِ png یا svg را برای یک شخصیتِ آواتار آپلود و با نامِ
|
||||
// <id>.<ext> ذخیره میکند و img_ext را در دیتابیس بهروزرسانی میکند. نوعِ فایل از
|
||||
// پسوندِ نامِ آپلودشده تشخیص داده میشود (svg → svg، وگرنه png). png برای شفافیت.
|
||||
func (h *Handler) avatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.avatarsDir == "" {
|
||||
http.Error(w, "AVATARS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "image لازم است", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(file, 16<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ext := "png"
|
||||
if strings.HasSuffix(strings.ToLower(hdr.Filename), ".svg") {
|
||||
ext = "svg"
|
||||
// «SVGِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن تا اپ رندرش کند.
|
||||
if raw, rext, ok := extractEmbeddedRaster(buf); ok {
|
||||
buf, ext = raw, rext
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(h.avatarsDir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// نسخههای قبلی با پسوندِ دیگر را پاک کن تا چند نسخه نماند.
|
||||
for _, e := range []string{"png", "jpg", "svg"} {
|
||||
if e != ext {
|
||||
_ = os.Remove(filepath.Join(h.avatarsDir, id+"."+e))
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(h.avatarsDir, id+"."+ext), buf, 0o644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = h.db.ExecContext(r.Context(), `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
// --- مدیریتِ بستههای چت و پیامها ---
|
||||
|
||||
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+128
-61
@@ -39,6 +39,11 @@ func (h *Handler) APIRoutes() http.Handler {
|
||||
r.Post("/frames", h.apiFrameUpsert)
|
||||
r.Delete("/frames", h.apiFrameDelete)
|
||||
|
||||
r.Get("/avatars", h.apiAvatars)
|
||||
r.Post("/avatars", h.apiAvatarUpsert)
|
||||
r.Delete("/avatars", h.apiAvatarDelete)
|
||||
r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png)
|
||||
|
||||
r.Get("/rank-tiers", h.apiRankTiers)
|
||||
r.Post("/rank-tiers", h.apiRankTierUpsert)
|
||||
|
||||
@@ -201,14 +206,14 @@ func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
UserID flexInt `json:"user_id"`
|
||||
Amount flexInt `json:"amount"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if err := h.eco.Adjust(r.Context(), b.UserID, economy.CurrencyCoin, b.Amount, "admin_adjust", "panel"); err != nil {
|
||||
if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_adjust", "panel"); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -217,26 +222,26 @@ func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Kind string `json:"kind"` // coin | ticket | vip
|
||||
Amount int64 `json:"amount"`
|
||||
VIPDays int `json:"vip_days"`
|
||||
UserID flexInt `json:"user_id"`
|
||||
Kind string `json:"kind"` // coin | ticket | vip
|
||||
Amount flexInt `json:"amount"`
|
||||
VIPDays flexInt `json:"vip_days"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var err error
|
||||
switch b.Kind {
|
||||
case "coin":
|
||||
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyCoin, b.Amount, "admin_grant", "panel")
|
||||
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel")
|
||||
case "ticket":
|
||||
err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyTicket, b.Amount, "admin_grant", "panel")
|
||||
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "admin_grant", "panel")
|
||||
case "vip":
|
||||
_, err = h.db.ExecContext(ctx,
|
||||
`UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`,
|
||||
fmt.Sprintf("+%d days", b.VIPDays), b.UserID)
|
||||
fmt.Sprintf("+%d days", b.VIPDays.i()), b.UserID.i())
|
||||
default:
|
||||
httpx.Error(w, http.StatusBadRequest, "unknown kind")
|
||||
return
|
||||
@@ -260,15 +265,22 @@ func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Sort int `json:"sort"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" ||
|
||||
strings.ContainsAny(b.ID, `/\.`) {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(b.ID, `/\.`) {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
@@ -277,7 +289,7 @@ func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
|
||||
b.ID, b.Title, b.PriceCoins, vip, b.Sort); err != nil {
|
||||
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -289,6 +301,57 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- شخصیتهای آواتار ---
|
||||
|
||||
func (h *Handler) apiAvatars(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"avatars": h.rows(r.Context(),
|
||||
`SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`,
|
||||
"id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "بدنهی نامعتبر")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(b.ID, `/\.`) {
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
if b.VIP {
|
||||
vip = 1
|
||||
}
|
||||
// ON CONFLICT بهجای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود.
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)
|
||||
ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins,
|
||||
vip=excluded.vip, sort=excluded.sort, enabled=1`,
|
||||
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars WHERE id=?`, r.URL.Query().Get("id"))
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- قابهای آواتار ---
|
||||
|
||||
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -301,17 +364,17 @@ func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort flexInt `json:"sort"`
|
||||
Enabled *flexBool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
vip := 0
|
||||
@@ -326,7 +389,7 @@ func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
b.ID, b.Title, b.PriceCoins, vip, clean(b.C1), clean(b.C2), b.Sort, enabled); err != nil {
|
||||
b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), enabled); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -350,20 +413,20 @@ func (h *Handler) apiRankTiers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort int `json:"sort"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
C1 string `json:"c1"`
|
||||
C2 string `json:"c2"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
|
||||
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort); err != nil {
|
||||
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -386,15 +449,15 @@ func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Sort int `json:"sort"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
PriceCoins flexInt `json:"price_coins"`
|
||||
VIP flexBool `json:"vip"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
|
||||
return
|
||||
}
|
||||
if b.Kind != "emoji" {
|
||||
@@ -406,7 +469,7 @@ func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
|
||||
b.ID, b.Title, b.Kind, b.PriceCoins, vip, b.Sort); err != nil {
|
||||
b.ID, b.Title, b.Kind, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -422,16 +485,16 @@ func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
PackID string `json:"pack_id"`
|
||||
Body string `json:"body"`
|
||||
Sort int `json:"sort"`
|
||||
PackID string `json:"pack_id"`
|
||||
Body string `json:"body"`
|
||||
Sort flexInt `json:"sort"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است")
|
||||
return
|
||||
}
|
||||
if _, err := h.db.ExecContext(r.Context(),
|
||||
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort); err != nil {
|
||||
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -452,13 +515,13 @@ func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type tournamentBody struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EntryFee int64 `json:"entry_fee"`
|
||||
Prizes []int64 `json:"prizes"`
|
||||
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
|
||||
EndsAt string `json:"ends_at"`
|
||||
ID flexInt `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EntryFee flexInt `json:"entry_fee"`
|
||||
Prizes []flexInt `json:"prizes"`
|
||||
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
|
||||
EndsAt string `json:"ends_at"`
|
||||
}
|
||||
|
||||
// tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل میکند.
|
||||
@@ -471,9 +534,13 @@ func tournamentFromBody(b tournamentBody) economy.Tournament {
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
prizes := make([]int64, len(b.Prizes))
|
||||
for i, p := range b.Prizes {
|
||||
prizes[i] = p.i()
|
||||
}
|
||||
return economy.Tournament{
|
||||
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee,
|
||||
Prizes: b.Prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
|
||||
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(),
|
||||
Prizes: prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +563,7 @@ func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := h.eco.UpdateTournament(r.Context(), b.ID, tournamentFromBody(b)); err != nil {
|
||||
if err := h.eco.UpdateTournament(r.Context(), b.ID.i(), tournamentFromBody(b)); err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// برخی ابزارها (مثلِ خروجیِ AI) یک PNG را داخلِ یک <svg> با base64 قرار میدهند.
|
||||
// flutter_svg نمیتواند این «SVGِ رستری» را رندر کند، پس آن را به PNG واقعی تبدیل
|
||||
// میکنیم.
|
||||
var rasterInSVG = regexp.MustCompile(`data:image/(png|jpe?g);base64,([A-Za-z0-9+/=\s]+)`)
|
||||
|
||||
// extractEmbeddedRaster اگر محتوای SVG صرفاً یک تصویرِ رستریِ base64 باشد، آن را
|
||||
// استخراج میکند (بایتهای خام + پسوند). برای SVGهای واقعیِ برداری false برمیگرداند.
|
||||
func extractEmbeddedRaster(svg []byte) (data []byte, ext string, ok bool) {
|
||||
m := rasterInSVG.FindSubmatch(svg)
|
||||
if m == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
ext = "png"
|
||||
if strings.HasPrefix(string(m[1]), "jp") {
|
||||
ext = "jpg"
|
||||
}
|
||||
b64 := regexp.MustCompile(`\s`).ReplaceAll(m[2], nil)
|
||||
raw, err := base64.StdEncoding.DecodeString(string(b64))
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
return raw, ext, true
|
||||
}
|
||||
|
||||
// convertRasterAvatars فایلهای آواتارِ svg را که در واقع رستر هستند به png/jpg
|
||||
// تبدیل میکند و img_ext را در DB بهروزرسانی میکند. یکبار در startup اجرا میشود
|
||||
// (idempotent). برای رفعِ آواتارهایی که قبلاً بهصورتِ «svgِ رستری» آپلود شدهاند.
|
||||
func convertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT id FROM avatars WHERE img_ext = 'svg'`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
svgPath := filepath.Join(dir, id+".svg")
|
||||
content, err := os.ReadFile(svgPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, ext, ok := extractEmbeddedRaster(content)
|
||||
if !ok {
|
||||
continue // SVGِ واقعیِ برداری — دست نزن.
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, id+"."+ext), raw, 0o644); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(svgPath)
|
||||
_, _ = db.ExecContext(ctx, `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertRasterAvatars نسخهی عمومی برای فراخوانی از main در startup.
|
||||
func ConvertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
|
||||
convertRasterAvatars(ctx, db, dir)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flexBool یک bool است که هنگامِ decode از JSON، مقادیرِ عددی/رشتهای را هم میپذیرد
|
||||
// (true/false، 0/1، "1"/"true"). دلیل: SQLite بولینها را int برمیگرداند و پنل
|
||||
// همان مقدار را دوباره میفرستد؛ decodeِ سختگیرِ Go عدد را در bool نمیپذیرد → 400.
|
||||
type flexBool bool
|
||||
|
||||
func (f *flexBool) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
*f = s == "1" || s == "true" || s == "1.0"
|
||||
return nil
|
||||
}
|
||||
|
||||
// flexInt یک int64 است که از عدد، رشتهی عددی، خالی یا null هم decode میشود
|
||||
// (تا ورودیهای متنیِ عددی مثلِ "2000" باعثِ 400 نشوند).
|
||||
type flexInt int64
|
||||
|
||||
func (f *flexInt) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
if s == "" || s == "null" {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64) // "2000" و "2000.0" هر دو
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*f = flexInt(int64(v))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f flexInt) i() int64 { return int64(f) }
|
||||
Reference in New Issue
Block a user