owpengram-server/cmd/telesrv-admin/server.go

2956 lines
105 KiB
Go

package main
import (
"bytes"
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"mime/multipart"
"net/http"
"path"
"strconv"
"strings"
"time"
"telesrv/internal/admin"
"telesrv/internal/domain"
)
//go:embed web/dist
var webDist embed.FS
type server struct {
cfg uiConfig
read *readStore
web fs.FS
webServer http.Handler
}
func newServer(cfg uiConfig, read *readStore) (*server, error) {
web, err := fs.Sub(webDist, "web/dist")
if err != nil {
return nil, err
}
return &server{
cfg: cfg,
read: read,
web: web,
webServer: http.FileServer(http.FS(web)),
}, nil
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleAPILogin)
// Logout goes through the same gate as every other mutating route: a forced
// logout is a state change, and an invalid session is cleared by the gate
// itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI)))
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
mux.Handle("GET /api/default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftsAPI)))
mux.Handle("GET /api/default-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftAnimationAPI)))
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/account-ratings", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingsAPI)))
mux.Handle("GET /api/account-ratings/{user_id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingDetailAPI)))
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
mux.Handle("POST /api/actions/import-default-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportDefaultStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllDefaultStarGiftsAPI)))
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllOfficialStarGiftsAPI)))
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/recompute-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleRecomputeAccountRatingAPI)))
mux.Handle("POST /api/actions/adjust-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleAdjustAccountRatingAPI)))
// Official platform verification. Every route needs verification.review;
// clearing an existing badge needs verification.revoke on top of it.
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
mux.Handle("GET /api/verification/applications/{id}", s.verificationRead(s.handleVerificationApplicationDetailAPI))
mux.Handle("GET /api/verification/counts", s.verificationRead(s.handleVerificationCountsAPI))
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI(
s.requirePermission(permissionVerificationReview,
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
// Third-party bot verification. A separate section from the official
// verification block above -- separate tables, separate rights, separate routes.
// Reads and queue decisions need botverification.review; appointing verifiers,
// curating the icon catalogue and stripping a granted mark need
// botverification.manage.
mux.Handle("GET /api/botverification/verifiers", s.botVerificationRead(s.handleBotVerifiersAPI))
mux.Handle("GET /api/botverification/icons", s.botVerificationRead(s.handleVerificationIconsAPI))
mux.Handle("GET /api/botverification/marks", s.botVerificationRead(s.handleCustomVerificationsAPI))
mux.Handle("GET /api/botverification/requests", s.botVerificationRead(s.handleCustomVerificationRequestsAPI))
mux.Handle("GET /api/botverification/requests/{id}", s.botVerificationRead(s.handleCustomVerificationRequestDetailAPI))
mux.Handle("GET /api/botverification/counts", s.botVerificationRead(s.handleCustomVerificationCountsAPI))
mux.Handle("POST /api/botverification/requests/{id}/approve", s.botVerificationRead(s.handleApproveBotVerificationAPI))
mux.Handle("POST /api/botverification/requests/{id}/reject", s.botVerificationRead(s.handleRejectBotVerificationAPI))
mux.Handle("POST /api/botverification/requests/{id}/revoke", s.botVerificationRead(s.handleRevokeBotVerificationAPI))
mux.Handle("POST /api/actions/grant-bot-verifier", s.botVerificationManage(s.handleGrantBotVerifierAPI))
mux.Handle("POST /api/actions/set-bot-verifier-enabled", s.botVerificationManage(s.handleSetBotVerifierEnabledAPI))
mux.Handle("POST /api/actions/revoke-bot-verifier", s.botVerificationManage(s.handleRevokeBotVerifierAPI))
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
mux.HandleFunc("/", s.handleApp)
return mux
}
type actorKey struct{}
func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor
}
return "admin"
}
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
clean := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if clean != "." && clean != "" {
if info, err := fs.Stat(s.web, clean); err == nil && !info.IsDir() {
s.webServer.ServeHTTP(w, r)
return
}
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
s.webServer.ServeHTTP(w, r2)
}
type loginRequest struct {
Secret string `json:"secret"`
}
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
// so the two never outlive each other.
const sessionTTL = 12 * time.Hour
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
// Login is the one mutating route without a CSRF token, because no session
// exists yet to bind one to. The Origin check still applies, and the request
// carries the operator credential, which a forging page does not have.
if !sameOriginRequest(r) {
writeAPIError(w, http.StatusForbidden, "origin is not allowed")
return
}
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !s.validSecret(req.Secret) {
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return
}
csrfToken, err := newCSRFToken()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
permissions := newPanelPermissions(s.cfg.Permissions)
value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin",
Exp: time.Now().Add(sessionTTL).Unix(),
Nonce: newCommandID("sess"),
Permissions: permissions.List(),
CSRF: csrfToken,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
setCSRFCookie(w, csrfToken, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{
"actor": "admin",
"permissions": permissions.List(),
"csrf_token": csrfToken,
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
})
}
func (s *server) validSecret(secret string) bool {
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
return true
}
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
return true
}
return false
}
func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleSession is what the panel asks on load. It reports the permissions the
// session carries, so the UI can hide a section the operator may not use rather
// than letting them walk into a 403.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"actor": actorFromContext(r.Context()),
"permissions": permissionsFromContext(r.Context()).List(),
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
})
}
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, err := s.read.ListStarGifts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"Gifts": rows})
}
func (s *server) handleEmojiAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []EmojiRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchEmoji(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListEmoji(r.Context(), beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].DocumentID
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleEmojiAnimationAPI(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/emoji/%d/animation", s.cfg.AdminAPIURL, documentID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
if err != nil || len(raw) > 4<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/gifts/%d/animation", s.cfg.AdminAPIURL, giftID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (4<<20)+1))
if err != nil || len(raw) > 4<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles", giftID), 4<<20)
}
func (s *server) handleDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/default-gifts", 4<<20)
}
func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
if _, err := strconv.Atoi(id); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
return
}
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
}
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
kind := r.PathValue("kind")
if err != nil || giftID <= 0 || attrErr != nil || attributeID <= 0 || (kind != "model" && kind != "pattern") {
writeAPIError(w, http.StatusBadRequest, "invalid collectible animation")
return
}
s.proxyAdminJSON(w, r, fmt.Sprintf("/v1/gifts/%d/collectibles/%s/%d/animation", giftID, kind, attributeID), 4<<20)
}
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "private, max-age=30")
}
func (s *server) proxyAdminJSONNoStore(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "no-store")
}
func (s *server) proxyAdminJSONWithCache(
w http.ResponseWriter,
r *http.Request,
apiPath string,
maxBytes int64,
cacheControl string,
) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil || int64(len(raw)) > maxBytes {
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *server) handleModerationCasesAPI(w http.ResponseWriter, r *http.Request) {
apiPath := "/v1/moderation/cases"
if r.URL.RawQuery != "" {
apiPath += "?" + r.URL.RawQuery
}
s.proxyAdminJSONNoStore(w, r, apiPath, 4<<20)
}
func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
return
}
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/cases/%d", id), 4<<20)
}
func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation report id")
return
}
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/reports/%d", id), 4<<20)
}
func (s *server) handleClaimModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "claim", false)
}
func (s *server) handleDecideModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "decide", true)
}
func (s *server) handleReviewModerationAppealAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "appeals/"+r.PathValue("appeal_id")+"/review", true)
}
func (s *server) proxyModerationWrite(w http.ResponseWriter, r *http.Request, suffix string, needsCommand bool) {
caseID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || caseID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
return
}
if strings.HasPrefix(suffix, "appeals/") {
appealID, err := strconv.ParseInt(r.PathValue("appeal_id"), 10, 64)
if err != nil || appealID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation appeal id")
return
}
}
defer r.Body.Close()
var payload map[string]any
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
if err := decoder.Decode(&payload); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid json")
return
}
payload["actor"] = actorFromContext(r.Context())
if needsCommand {
commandID, _ := payload["command_id"].(string)
if strings.TrimSpace(commandID) == "" {
payload["command_id"] = newCommandID("moderation")
}
}
raw, err := json.Marshal(payload)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
s.relayAdminJSON(
w, r, http.MethodPost,
fmt.Sprintf("/v1/moderation/cases/%d/%s", caseID, suffix),
raw, 4<<20,
)
}
func (s *server) relayAdminJSON(w http.ResponseWriter, r *http.Request, method, apiPath string, body []byte, maxBytes int64) {
req, err := http.NewRequestWithContext(
r.Context(), method, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body),
)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil || int64(len(raw)) > maxBytes {
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(raw)
}
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
beforeActiveUS, _ := parseInt64(r.URL.Query().Get("before_active_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []AccountRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, hasMore, err = s.read.SearchAccounts(r.Context(), q, beforeActiveUS, beforeID, limit)
} else {
rows, hasMore, err = s.read.ListAccounts(r.Context(), beforeActiveUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeActiveUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeActiveUS = last.LastActiveAt.UnixMicro()
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"next_before_active_us": nextBeforeActiveUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleSharedDeviceGroupsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
offset, _ := parseInt(r.URL.Query().Get("offset"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
groups, hasMore, err := s.read.ListSharedDeviceGroups(r.Context(), offset, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"limit": limit,
"offset": offset,
"rows": groups,
"has_more": hasMore,
"next_offset": offset + limit,
})
}
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
total, err := s.read.CountAccounts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
online, err := s.read.CountOnlineAccounts(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"total": total,
"online": online,
})
}
func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
userID, err := parseInt64(r.PathValue("id"))
if err != nil || userID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.AccountDetail(r.Context(), userID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
// handleAccountAvatarAPI streams the account's current profile photo straight
// through from the real telesrv admin API (/v1/accounts/{id}/avatar) — unlike
// every other /api/* handler here, the response is raw image bytes, not JSON,
// so it can't reuse callAdminAPI/writeJSON.
func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request) {
userID, err := parseInt64(r.PathValue("id"))
if err != nil || userID <= 0 {
http.NotFound(w, r)
return
}
apiPath := fmt.Sprintf("/v1/accounts/%d/avatar", userID)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
http.NotFound(w, r)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.NotFound(w, r)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.NotFound(w, r)
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil || len(data) == 0 {
http.NotFound(w, r)
return
}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Cache-Control", "private, max-age=300")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// handleChannelAvatarAPI streams a channel's current avatar straight through
// from the real telesrv admin API (/v1/channels/{id}/avatar), mirroring
// handleAccountAvatarAPI.
func (s *server) handleChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
channelID, err := parseInt64(r.PathValue("id"))
if err != nil || channelID <= 0 {
http.NotFound(w, r)
return
}
apiPath := fmt.Sprintf("/v1/channels/%d/avatar", channelID)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
http.NotFound(w, r)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.NotFound(w, r)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.NotFound(w, r)
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil || len(data) == 0 {
http.NotFound(w, r)
return
}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Cache-Control", "private, max-age=300")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
type setChannelAvatarAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
}
func (s *server) handleSetChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body setChannelAvatarAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req := admin.SetChannelAvatarRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-avatar"),
ChannelID: body.ChannelID,
FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/channels/set-avatar", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []BotRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchBots(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListBots(r.Context(), beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
if hasMore && len(rows) > 0 {
nextBeforeID = rows[len(rows)-1].ID
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleBotDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
botID, err := parseInt64(r.PathValue("id"))
if err != nil || botID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.BotDetail(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
type createBotAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
Name string `json:"name"`
Username string `json:"username"`
}
func (s *server) handleCreateBotAPI(w http.ResponseWriter, r *http.Request) {
var body createBotAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.CreateBotRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-bot"),
OwnerUserID: body.OwnerUserID,
Name: body.Name,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/create", req)
writeCommandResultAPI(w, result, err)
}
type deleteBotAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotUserID int64 `json:"bot_user_id"`
}
func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
var body deleteBotAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteBotRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-bot"),
BotUserID: body.BotUserID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/delete", req)
writeCommandResultAPI(w, result, err)
}
type exportBotTokenAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
BotUserID int64 `json:"bot_user_id"`
}
func (s *server) handleExportBotTokenAPI(w http.ResponseWriter, r *http.Request) {
var body exportBotTokenAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ExportBotTokenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "export-bot-token"),
BotUserID: body.BotUserID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/bots/export-token", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query().Get("q")
beforeID, _ := parseInt64(r.URL.Query().Get("before_id"))
beforeUpdatedUS, _ := parseInt64(r.URL.Query().Get("before_updated_us"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
rows := []ChannelRow{}
hasMore := false
var err error
if strings.TrimSpace(q) != "" {
rows, err = s.read.SearchChannels(r.Context(), q)
} else {
rows, hasMore, err = s.read.ListChannels(r.Context(), beforeUpdatedUS, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := int64(0)
nextBeforeUpdatedUS := int64(0)
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
nextBeforeID = last.ID
nextBeforeUpdatedUS = last.UpdatedAt.UnixMicro()
}
if limit <= 0 {
limit = channelListDefaultLimit
}
if limit > channelListMaxLimit {
limit = channelListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"query": q,
"limit": limit,
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
"next_before_updated_us": nextBeforeUpdatedUS,
"listing": strings.TrimSpace(q) == "",
})
}
func (s *server) handleChannelDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err := parseInt64(r.PathValue("id"))
if err != nil || channelID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.ChannelDetail(r.Context(), channelID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
owner, _ := parseInt64(q.Get("owner_user_id"))
peer, _ := parseInt64(q.Get("peer_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []MessageRow{}
var err error
if owner > 0 && peer > 0 {
rows, err = s.read.ListMessages(r.Context(), owner, peer, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"owner_user_id": owner,
"peer_id": peer,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
owner, err1 := parseInt64(r.URL.Query().Get("owner_user_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || owner <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner/msg_id")
return
}
detail, err := s.read.MessageDetail(r.Context(), owner, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
func (s *server) handleGroupMessagesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
q := r.URL.Query()
channelID, _ := parseInt64(q.Get("channel_id"))
beforeDate, _ := parseInt64(q.Get("before_date"))
beforeID, _ := parseInt(q.Get("before_id"))
limit, _ := parseInt(q.Get("limit"))
rows := []GroupMessageRow{}
var err error
if channelID > 0 {
rows, err = s.read.ListGroupMessages(r.Context(), channelID, beforeDate, beforeID, limit)
}
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 || limit > messagePageLimit {
limit = messagePageLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"channel_id": channelID,
"before_date": beforeDate,
"before_id": beforeID,
"limit": limit,
"rows": rows,
})
}
func (s *server) handleGroupMessageDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
channelID, err1 := parseInt64(r.URL.Query().Get("channel_id"))
msgID, err2 := parseInt(r.URL.Query().Get("msg_id"))
if err1 != nil || err2 != nil || channelID <= 0 || msgID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid channel_id/msg_id")
return
}
detail, err := s.read.GroupMessageDetail(r.Context(), channelID, msgID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, detail)
}
type setAccountFrozenAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Frozen bool `json:"frozen"`
Until time.Time `json:"freeze_until"`
AppealURL string `json:"freeze_appeal_url"`
}
func (s *server) handleSetAccountFrozenAPI(w http.ResponseWriter, r *http.Request) {
var body setAccountFrozenAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetAccountFrozenRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-frozen"),
UserID: body.UserID,
Frozen: body.Frozen,
Until: body.Until,
AppealURL: body.AppealURL,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-frozen", req)
writeCommandResultAPI(w, result, err)
}
type grantPremiumAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Months int `json:"months"`
}
func (s *server) handleGrantPremiumAPI(w http.ResponseWriter, r *http.Request) {
var body grantPremiumAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GrantPremiumRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-premium"),
UserID: body.UserID,
Months: body.Months,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-premium", req)
writeCommandResultAPI(w, result, err)
}
type grantStarsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Amount int64 `json:"amount"`
}
func (s *server) handleGrantStarsAPI(w http.ResponseWriter, r *http.Request) {
var body grantStarsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GrantStarsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-stars"),
UserID: body.UserID,
Amount: body.Amount,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/grant-stars", req)
writeCommandResultAPI(w, result, err)
}
type setVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verified"),
UserID: body.UserID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type setUserFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetUserFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setUserFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-flags"),
UserID: body.UserID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setChannelFlagsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Scam bool `json:"scam"`
Fake bool `json:"fake"`
}
func (s *server) handleSetChannelFlagsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelFlagsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelFlagsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-flags"),
ChannelID: body.ChannelID,
Scam: body.Scam,
Fake: body.Fake,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-flags", req)
writeCommandResultAPI(w, result, err)
}
type setSupportAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Support bool `json:"support"`
}
func (s *server) handleSetSupportAPI(w http.ResponseWriter, r *http.Request) {
var body setSupportAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetSupportRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-support"),
UserID: body.UserID,
Support: body.Support,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-support", req)
writeCommandResultAPI(w, result, err)
}
type setUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
}
func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-username"),
UserID: body.UserID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setProfileAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
func (s *server) handleSetProfileAPI(w http.ResponseWriter, r *http.Request) {
var body setProfileAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetProfileRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-profile"),
UserID: body.UserID,
FirstName: body.FirstName,
LastName: body.LastName,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-profile", req)
writeCommandResultAPI(w, result, err)
}
type setPhoneAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Phone string `json:"phone"`
}
func (s *server) handleSetPhoneAPI(w http.ResponseWriter, r *http.Request) {
var body setPhoneAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetPhoneRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-phone"),
UserID: body.UserID,
Phone: body.Phone,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-phone", req)
writeCommandResultAPI(w, result, err)
}
type setLoginEmailAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Email string `json:"email"`
}
func (s *server) handleSetLoginEmailAPI(w http.ResponseWriter, r *http.Request) {
var body setLoginEmailAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetLoginEmailRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-email"),
UserID: body.UserID,
Email: body.Email,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-login-email", req)
writeCommandResultAPI(w, result, err)
}
type setAccountAvatarAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
}
func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body setAccountAvatarAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req := admin.SetAccountAvatarRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar"),
UserID: body.UserID,
FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetUserColorAPI(w http.ResponseWriter, r *http.Request) {
var body setUserColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-color"),
UserID: body.UserID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setUserEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetUserEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setUserEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetUserEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-account-emoji-status"),
UserID: body.UserID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelSettingsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Gigagroup *bool `json:"gigagroup,omitempty"`
AntiSpam *bool `json:"antispam,omitempty"`
ParticipantsHidden *bool `json:"participants_hidden,omitempty"`
NoForwards *bool `json:"noforwards,omitempty"`
JoinToSend *bool `json:"join_to_send,omitempty"`
JoinRequest *bool `json:"join_request,omitempty"`
SlowmodeSeconds *int `json:"slowmode_seconds,omitempty"`
}
func (s *server) handleSetChannelSettingsAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelSettingsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelSettingsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-settings"),
ChannelID: body.ChannelID,
Gigagroup: body.Gigagroup,
AntiSpam: body.AntiSpam,
ParticipantsHidden: body.ParticipantsHidden,
NoForwards: body.NoForwards,
JoinToSend: body.JoinToSend,
JoinRequest: body.JoinRequest,
SlowmodeSeconds: body.SlowmodeSeconds,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-settings", req)
writeCommandResultAPI(w, result, err)
}
type setChannelUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Username string `json:"username"`
}
func (s *server) handleSetChannelUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-username"),
ChannelID: body.ChannelID,
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-username", req)
writeCommandResultAPI(w, result, err)
}
type setChannelColorAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
ForProfile bool `json:"for_profile"`
HasColor bool `json:"has_color"`
Color int `json:"color"`
BackgroundEmojiID int64 `json:"background_emoji_id,string"`
}
func (s *server) handleSetChannelColorAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelColorAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelColorRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-color"),
ChannelID: body.ChannelID,
PeerColorInput: admin.PeerColorInput{
ForProfile: body.ForProfile, HasColor: body.HasColor, Color: body.Color, BackgroundEmojiID: body.BackgroundEmojiID,
},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-color", req)
writeCommandResultAPI(w, result, err)
}
type setChannelEmojiStatusAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
DocumentID int64 `json:"document_id,string"`
Until int `json:"until"`
}
func (s *server) handleSetChannelEmojiStatusAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelEmojiStatusAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelEmojiStatusRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-emoji-status"),
ChannelID: body.ChannelID,
EmojiStatusInput: admin.EmojiStatusInput{DocumentID: body.DocumentID, Until: body.Until},
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-emoji-status", req)
writeCommandResultAPI(w, result, err)
}
type setChannelVerifiedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
Verified bool `json:"verified"`
}
func (s *server) handleSetChannelVerifiedAPI(w http.ResponseWriter, r *http.Request) {
var body setChannelVerifiedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetChannelVerifiedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-verified"),
ChannelID: body.ChannelID,
Verified: body.Verified,
}
result, err := s.callAdminAPI(r.Context(), "/v1/channels/set-verified", req)
writeCommandResultAPI(w, result, err)
}
type revokeSessionsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
Hash int64 `json:"hash"`
KeepHash int64 `json:"keep_hash"`
RevokeAll bool `json:"revoke_all"`
}
func (s *server) handleRevokeSessionsAPI(w http.ResponseWriter, r *http.Request) {
var body revokeSessionsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RevokeSessionsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-sessions"),
UserID: body.UserID,
Hash: body.Hash,
KeepHash: body.KeepHash,
RevokeAll: body.RevokeAll,
}
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/revoke-sessions", req)
writeCommandResultAPI(w, result, err)
}
type deleteMessagesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
IDs []int `json:"ids"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteMessagesAPI(w http.ResponseWriter, r *http.Request) {
var body deleteMessagesAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateMessagesRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-messages"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
IDs: body.IDs,
Revoke: body.Revoke,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete", req)
writeCommandResultAPI(w, result, err)
}
type deleteHistoryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
OwnerUserID int64 `json:"owner_user_id"`
PeerID int64 `json:"peer_id"`
MaxID int `json:"max_id"`
MinDate int `json:"min_date"`
MaxDate int `json:"max_date"`
MaxBatches int `json:"max_batches"`
JustClear bool `json:"just_clear"`
Revoke bool `json:"revoke"`
}
func (s *server) handleDeleteHistoryAPI(w http.ResponseWriter, r *http.Request) {
var body deleteHistoryAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeletePrivateHistoryRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-history"),
OwnerUserID: body.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: body.PeerID},
MaxID: body.MaxID,
MinDate: body.MinDate,
MaxDate: body.MaxDate,
JustClear: body.JustClear,
Revoke: body.Revoke,
MaxBatches: body.MaxBatches,
}
result, err := s.callAdminAPI(r.Context(), "/v1/messages/delete-history", req)
writeCommandResultAPI(w, result, err)
}
type importStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
func (s *server) handleImportStarGiftAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body importStarGiftAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "animation file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
writeAPIError(w, http.StatusBadRequest, "animation file is empty or too large")
return
}
req := admin.ImportStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-gift"),
GiftID: body.GiftID,
Title: body.Title,
Stars: body.Stars,
ConvertStars: body.ConvertStars,
Enabled: body.Enabled,
SortOrder: body.SortOrder,
FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/gifts/import", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type importDefaultStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ID int `json:"id"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importDefaultStarGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
if body.ID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid default gift id")
return
}
req := admin.ImportDefaultStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
ID: body.ID,
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
writeCommandResultAPI(w, result, err)
}
type importAllDefaultStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
var body importAllDefaultStarGiftsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ImportAllDefaultStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
writeCommandResultAPI(w, result, err)
}
type importOfficialStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SourceGiftID string `json:"source_gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
}
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importOfficialStarGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
req := admin.ImportOfficialStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
writeCommandResultAPI(w, result, err)
}
type importAllOfficialStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
var body importAllOfficialStarGiftsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ImportAllOfficialStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
writeCommandResultAPI(w, result, err)
}
type publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
Models []admin.StarGiftCollectibleAnimationUpload `json:"models"`
Patterns []admin.StarGiftCollectibleAnimationUpload `json:"patterns"`
Backdrops []admin.StarGiftCollectibleBackdropInput `json:"backdrops"`
}
func (s *server) handlePublishStarGiftCollectiblesAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
giftID, err := strconv.ParseInt(r.URL.Query().Get("gift_id"), 10, 64)
if err != nil || giftID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid gift id")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid collectible multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body publishStarGiftCollectiblesAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
if len(body.Models)+len(body.Patterns) > 128 {
writeAPIError(w, http.StatusBadRequest, "too many collectible animation files")
return
}
seen := make(map[string]struct{}, len(body.Models)+len(body.Patterns))
load := func(upload *admin.StarGiftCollectibleAnimationUpload) error {
upload.FileKey = strings.TrimSpace(upload.FileKey)
if upload.FileKey == "" {
return errors.New("animation file key is required")
}
if _, ok := seen[upload.FileKey]; ok {
return fmt.Errorf("duplicate animation file key %q", upload.FileKey)
}
seen[upload.FileKey] = struct{}{}
file, header, err := r.FormFile(upload.FileKey)
if err != nil {
return fmt.Errorf("animation file %q is required", upload.FileKey)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (4<<20)+1))
if err != nil || len(data) == 0 || len(data) > 4<<20 {
return fmt.Errorf("animation file %q is empty or too large", upload.FileKey)
}
upload.FileName = header.Filename
upload.Data = data
return nil
}
for i := range body.Models {
if err := load(&body.Models[i]); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
for i := range body.Patterns {
if err := load(&body.Patterns[i]); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
req := admin.PublishStarGiftCollectiblesRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "publish-gift-collectibles"),
GiftID: giftID, UpgradeStars: body.UpgradeStars, SupplyTotal: body.SupplyTotal,
SlugPrefix: body.SlugPrefix, Models: body.Models, Patterns: body.Patterns, Backdrops: body.Backdrops,
}
result, err := s.callAdminCollectibleMultipart(r.Context(), fmt.Sprintf("/v1/gifts/%d/collectibles/publish", giftID), req)
writeCommandResultAPI(w, result, err)
}
type setStarGiftEnabledAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id,string"`
Enabled bool `json:"enabled"`
}
func (s *server) handleSetStarGiftEnabledAPI(w http.ResponseWriter, r *http.Request) {
var body setStarGiftEnabledAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStarGiftEnabledRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-enabled"),
GiftID: body.GiftID, Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-enabled", req)
writeCommandResultAPI(w, result, err)
}
type setStarGiftSortOrderAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
GiftID int64 `json:"gift_id,string"`
SortOrder int `json:"sort_order"`
}
func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Request) {
var body setStarGiftSortOrderAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStarGiftSortOrderRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gift-sort-order"),
GiftID: body.GiftID, SortOrder: body.SortOrder,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/set-sort-order", req)
writeCommandResultAPI(w, result, err)
}
type renameStickerSetAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
Title string `json:"title"`
}
func (s *server) handleRenameStickerSetAPI(w http.ResponseWriter, r *http.Request) {
var body renameStickerSetAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RenameStickerSetRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "rename-sticker-set"),
SetID: body.SetID, Title: body.Title,
}
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/rename", req)
writeCommandResultAPI(w, result, err)
}
type deleteStickerSetAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
}
func (s *server) handleDeleteStickerSetAPI(w http.ResponseWriter, r *http.Request) {
var body deleteStickerSetAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteStickerSetRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-sticker-set"),
SetID: body.SetID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/delete", req)
writeCommandResultAPI(w, result, err)
}
type createStickerSetAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Title string `json:"title"`
ShortName string `json:"short_name"`
Kind string `json:"kind"`
Emoji string `json:"emoji"`
Keywords string `json:"keywords,omitempty"`
}
func (s *server) handleCreateStickerSetAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body createStickerSetAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "sticker file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
if err != nil || len(data) == 0 || len(data) > 20<<20 {
writeAPIError(w, http.StatusBadRequest, "sticker file is empty or too large")
return
}
req := admin.CreateStickerSetRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-sticker-set"),
Title: body.Title, ShortName: body.ShortName, Kind: body.Kind,
Emoji: body.Emoji, Keywords: body.Keywords, FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/stickers/create", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type addStickerToSetAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
Emoji string `json:"emoji"`
Keywords string `json:"keywords,omitempty"`
}
func (s *server) handleAddStickerToSetAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body addStickerToSetAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "sticker file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
if err != nil || len(data) == 0 || len(data) > 20<<20 {
writeAPIError(w, http.StatusBadRequest, "sticker file is empty or too large")
return
}
req := admin.AddStickerToSetRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "add-sticker-to-set"),
SetID: body.SetID, Emoji: body.Emoji, Keywords: body.Keywords, FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/stickers/add", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type removeStickerFromSetAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
DocumentID int64 `json:"document_id,string"`
}
func (s *server) handleRemoveStickerFromSetAPI(w http.ResponseWriter, r *http.Request) {
var body removeStickerFromSetAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RemoveStickerFromSetRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-sticker-from-set"),
SetID: body.SetID, DocumentID: body.DocumentID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/remove", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
rows, err := s.read.ListStickerSets(r.Context(), r.URL.Query().Get("kind"))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
func (s *server) handleStickerSetDocumentsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
setID, err := parseInt64(r.PathValue("id"))
if err != nil || setID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
ids, err := s.read.StickerSetDocumentIDs(r.Context(), setID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"document_ids": ids})
}
// handleStickerDocumentAnimationAPI proxies one document's decompressed Lottie
// JSON from the real telesrv admin API — mirrors handleStarGiftAnimationAPI.
func (s *server) handleStickerDocumentAnimationAPI(w http.ResponseWriter, r *http.Request) {
documentID, err := parseInt64(r.PathValue("id"))
if err != nil || documentID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/stickers/documents/%d/animation", s.cfg.AdminAPIURL, documentID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
if err != nil || len(raw) > 8<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid animation response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
// Content-Type is passed through as-is: the underlying document may be a
// decompressed Lottie animation (application/json) or a plain static
// raster sticker (image/webp, image/png, ...) — see
// admin.Service.StickerDocumentAnimation.
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
type setStickerSetArchivedAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
Archived bool `json:"archived"`
}
func (s *server) handleSetStickerSetArchivedAPI(w http.ResponseWriter, r *http.Request) {
var body setStickerSetArchivedAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStickerSetArchivedRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-sticker-set-archived"),
SetID: body.SetID, Archived: body.Archived,
}
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/set-archived", req)
writeCommandResultAPI(w, result, err)
}
type setStickerSetSortOrderAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SetID int64 `json:"set_id,string"`
SortOrder int `json:"sort_order"`
}
func (s *server) handleSetStickerSetSortOrderAPI(w http.ResponseWriter, r *http.Request) {
var body setStickerSetSortOrderAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetStickerSetSortOrderRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-sticker-set-sort-order"),
SetID: body.SetID, SortOrder: body.SortOrder,
}
result, err := s.callAdminAPI(r.Context(), "/v1/stickers/set-sort-order", req)
writeCommandResultAPI(w, result, err)
}
type giveGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SenderUserID int64 `json:"sender_user_id"`
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
GiftID int64 `json:"gift_id,string"`
HideName bool `json:"hide_name"`
Message string `json:"message"`
Upgrade bool `json:"upgrade"`
ModelAttributeID int64 `json:"model_attribute_id,string"`
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
}
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
var body giveGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.GiveGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "give-gift"),
SenderUserID: body.SenderUserID,
UserID: body.UserID,
ChannelID: body.ChannelID,
GiftID: body.GiftID,
HideName: body.HideName,
Message: body.Message,
Upgrade: body.Upgrade,
ModelAttributeID: body.ModelAttributeID,
PatternAttributeID: body.PatternAttributeID,
BackdropAttributeID: body.BackdropAttributeID,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
writeCommandResultAPI(w, result, err)
}
// flexInt64 decodes an int64 the panel may send either as a JSON number or as a
// decimal string. Ids and nanoton amounts are sent as strings to stay exact past
// 2^53, while a picker-supplied peer id arrives as a plain number; an empty
// string and null both mean "unset", which is how an untouched form field looks.
type flexInt64 int64
// Int64 returns the decoded value.
func (v flexInt64) Int64() int64 { return int64(v) }
func (v *flexInt64) UnmarshalJSON(raw []byte) error {
text, empty := flexScalarText(raw)
if empty {
*v = 0
return nil
}
parsed, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return fmt.Errorf("invalid integer %s", string(raw))
}
*v = flexInt64(parsed)
return nil
}
// flexUnix decodes an optional timestamp as a Unix second count. A date input
// produces an RFC3339 string and a scripted call a plain number, so both are
// accepted; empty means "unset", which the mint command stamps with its clock.
type flexUnix int64
// Unix returns the decoded timestamp in seconds, or zero when unset.
func (v flexUnix) Unix() int64 { return int64(v) }
func (v *flexUnix) UnmarshalJSON(raw []byte) error {
text, empty := flexScalarText(raw)
if empty {
*v = 0
return nil
}
if parsed, err := strconv.ParseInt(text, 10, 64); err == nil {
*v = flexUnix(parsed)
return nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if parsed, err := time.Parse(layout, text); err == nil {
*v = flexUnix(parsed.UTC().Unix())
return nil
}
}
return fmt.Errorf("invalid timestamp %s", string(raw))
}
// flexScalarText unwraps a JSON scalar to its textual form and reports whether
// it carries no value at all (null, empty string, blank).
func flexScalarText(raw []byte) (string, bool) {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return "", true
}
if unquoted, err := strconv.Unquote(text); err == nil {
text = strings.TrimSpace(unquoted)
}
if text == "" {
return "", true
}
return text, false
}
type mintCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
OwnerUserID flexInt64 `json:"owner_user_id"`
OwnerChannelID flexInt64 `json:"owner_channel_id"`
Currency string `json:"currency"`
Amount flexInt64 `json:"amount"`
CryptoCurrency string `json:"crypto_currency"`
CryptoAmount flexInt64 `json:"crypto_amount"`
URL string `json:"url"`
PurchaseDate flexUnix `json:"purchase_date"`
}
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.MintCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "mint-collectible-username"),
Username: body.Username,
OwnerUserID: body.OwnerUserID.Int64(),
OwnerChannelID: body.OwnerChannelID.Int64(),
Currency: body.Currency,
Amount: body.Amount.Int64(),
CryptoCurrency: body.CryptoCurrency,
CryptoAmount: body.CryptoAmount.Int64(),
URL: body.URL,
PurchaseDate: body.PurchaseDate.Unix(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/mint", req)
writeCommandResultAPI(w, result, err)
}
type transferCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
ToUserID flexInt64 `json:"to_user_id"`
ToChannelID flexInt64 `json:"to_channel_id"`
}
func (s *server) handleTransferCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body transferCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.TransferCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "transfer-collectible-username"),
Username: body.Username,
ToUserID: body.ToUserID.Int64(),
ToChannelID: body.ToChannelID.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/transfer", req)
writeCommandResultAPI(w, result, err)
}
type revokeCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
Burn bool `json:"burn"`
}
func (s *server) handleRevokeCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body revokeCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
prefix := "revoke-collectible-username"
if body.Burn {
prefix = "burn-collectible-username"
}
req := admin.RevokeCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, prefix),
Username: body.Username,
Burn: body.Burn,
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/revoke", req)
writeCommandResultAPI(w, result, err)
}
type deleteCollectibleUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
}
// handleDeleteCollectibleUsernameAPI erases an asset and its provenance. The
// panel gates it behind the same reason + dry-run + confirm flow as a burn, but
// the outcome differs: the name becomes issuable again from scratch.
func (s *server) handleDeleteCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body deleteCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteCollectibleUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-collectible-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/delete", req)
writeCommandResultAPI(w, result, err)
}
type recomputeAccountRatingAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID flexInt64 `json:"user_id"`
}
func (s *server) handleRecomputeAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
var body recomputeAccountRatingAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.RecomputeAccountRatingRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "recompute-account-rating"),
UserID: body.UserID.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/recompute", req)
writeCommandResultAPI(w, result, err)
}
type adjustAccountRatingAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID flexInt64 `json:"user_id"`
Amount flexInt64 `json:"amount"`
}
func (s *server) handleAdjustAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
var body adjustAccountRatingAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.AdjustAccountRatingRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "adjust-account-rating"),
UserID: body.UserID.Int64(),
Amount: body.Amount.Int64(),
}
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/adjust", req)
writeCommandResultAPI(w, result, err)
}
// handleCollectibleUsernamesAPI pages the collectible asset table straight from
// PostgreSQL, like every other table view, and echoes the keyset cursor as a
// decimal string so an int64 id survives the round trip through the browser.
func (s *server) handleCollectibleUsernamesAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
query := r.URL.Query()
status := strings.TrimSpace(query.Get("status"))
switch status {
case "", string(domain.CollectibleUsernameStatusVault),
string(domain.CollectibleUsernameStatusOwned),
string(domain.CollectibleUsernameStatusBurned):
default:
writeAPIError(w, http.StatusBadRequest, "invalid status")
return
}
ownerUserID, err := parseInt64(query.Get("owner_user_id"))
if err != nil || ownerUserID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid owner_user_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
rows, hasMore, err := s.read.ListCollectibleUsernames(r.Context(), status, ownerUserID, beforeID, query.Get("q"), limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleCollectibleUsernameDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
id, err := parseInt64(r.PathValue("id"))
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid id")
return
}
detail, err := s.read.CollectibleUsernameDetail(r.Context(), id)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "collectible username not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": detail.Asset,
"transfers": detail.Transfers,
})
}
// handleAccountRatingsAPI pages the leaderboard. next_before_id is the last
// user id: the keyset predicate resolves the full (level, stars, user_id) cursor
// from it, so one opaque-looking value is enough to continue the page.
func (s *server) handleAccountRatingsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
query := r.URL.Query()
minLevel, err := parseInt(query.Get("min_level"))
if err != nil || minLevel < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid min_level")
return
}
userID, err := parseInt64(query.Get("user_id"))
if err != nil || userID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
return
}
beforeID, err := parseInt64(query.Get("before_id"))
if err != nil || beforeID < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
rows, hasMore, err := s.read.ListAccountRatings(r.Context(), minLevel, userID, beforeID, limit, query.Get("q"))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextBeforeID := ""
if hasMore && len(rows) > 0 {
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].UserID, 10)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_before_id": nextBeforeID,
})
}
func (s *server) handleStorageStatsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
stats, err := s.read.StorageStats(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
// handleStorageAccountsAPI pages the per-account storage breakdown,
// largest first.
func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
query := r.URL.Query()
offset, err := parseInt(query.Get("offset"))
if err != nil || offset < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid offset")
return
}
limit, err := parseInt(query.Get("limit"))
if err != nil || limit < 0 {
writeAPIError(w, http.StatusBadRequest, "invalid limit")
return
}
rows, hasMore, err := s.read.ListAccountStorageUsage(r.Context(), offset, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
nextOffset := 0
if hasMore {
nextOffset = offset + len(rows)
}
writeJSON(w, http.StatusOK, map[string]any{
"rows": rows,
"has_more": hasMore,
"next_offset": nextOffset,
})
}
func (s *server) handleAccountRatingDetailAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
userID, err := parseInt64(r.PathValue("user_id"))
if err != nil || userID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
return
}
detail, err := s.read.AccountRatingDetail(r.Context(), userID)
if err != nil {
if errors.Is(err, errReadNotFound) {
writeAPIError(w, http.StatusNotFound, "account rating not found")
return
}
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": detail.Rating,
"events": detail.Events,
})
}
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") {
commandID = ""
}
dryRun := !confirm
if commandID == "" {
scope := "dry"
if !dryRun {
scope = "exec"
}
commandID = newCommandID(scope + "-" + prefix)
}
return admin.CommandMeta{
CommandID: commandID,
Actor: actorFromContext(r.Context()),
Reason: reason,
DryRun: dryRun,
}
}
func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any) (admin.CommandResult, error) {
body, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
// callAdminCommand is callAdminAPI with the upstream status preserved.
//
// callAdminAPI deliberately loses it: every caller it has answers 502 for any
// failure. A verification decision needs the distinction, so this variant returns
// the HTTP status alongside the result and lets the handler map it. A status of 0
// means no HTTP answer was obtained at all.
func (s *server) callAdminCommand(ctx context.Context, apiPath string, payload any) (admin.CommandResult, int, error) {
body, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
if err != nil {
return admin.CommandResult{}, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, 0, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, resp.StatusCode, errors.New(result.Error)
}
return result, resp.StatusCode, nil
}
func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
meta, err := json.Marshal(metadata)
if err != nil {
return admin.CommandResult{}, err
}
if err := writer.WriteField("metadata", string(meta)); err != nil {
return admin.CommandResult{}, err
}
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return admin.CommandResult{}, err
}
if _, err := part.Write(data); err != nil {
return admin.CommandResult{}, err
}
if err := writer.Close(); err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func (s *server) callAdminCollectibleMultipart(ctx context.Context, apiPath string, payload admin.PublishStarGiftCollectiblesRequest) (admin.CommandResult, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
meta, err := json.Marshal(payload)
if err != nil {
return admin.CommandResult{}, err
}
if err := writer.WriteField("metadata", string(meta)); err != nil {
return admin.CommandResult{}, err
}
writeUploads := func(uploads []admin.StarGiftCollectibleAnimationUpload) error {
for _, upload := range uploads {
part, err := writer.CreateFormFile(upload.FileKey, upload.FileName)
if err != nil {
return err
}
if _, err := part.Write(upload.Data); err != nil {
return err
}
}
return nil
}
if err := writeUploads(payload.Models); err != nil {
return admin.CommandResult{}, err
}
if err := writeUploads(payload.Patterns); err != nil {
return admin.CommandResult{}, err
}
if err := writer.Close(); err != nil {
return admin.CommandResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, &body)
if err != nil {
return admin.CommandResult{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return admin.CommandResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var result admin.CommandResult
if err := json.Unmarshal(raw, &result); err != nil {
return result, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if result.Error == "" {
result.Error = resp.Status
}
return result, errors.New(result.Error)
}
return result, nil
}
func decodeAction(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := decodeJSON(r, dst); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return false
}
return true
}
func decodeJSON(r *http.Request, dst any) error {
defer r.Body.Close()
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
return nil
}
func writeCommandResultAPI(w http.ResponseWriter, result admin.CommandResult, err error) {
if err != nil {
if result.Status == "" {
result.Status = "failed"
}
if result.Message == "" {
result.Message = "command failed"
}
if result.Error == "" {
result.Error = err.Error()
}
writeJSON(w, http.StatusBadGateway, result)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func parseInt64(v string) (int64, error) {
v = strings.TrimSpace(v)
if v == "" {
return 0, nil
}
return strconv.ParseInt(v, 10, 64)
}
func parseInt(v string) (int, error) {
n, err := parseInt64(v)
return int(n), err
}
func boolValue(v bool) string {
if v {
return "1"
}
return "0"
}
func displayPhone(v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "+") {
return v
}
for _, r := range v {
if r < '0' || r > '9' {
return v
}
}
return "+" + v
}
func channelKind(ch ChannelRow) string {
if ch.Broadcast && !ch.Megagroup {
return "频道"
}
if ch.Megagroup {
if ch.Forum {
return "超级群/论坛"
}
return "超级群"
}
return "频道/群"
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}