added avatar and login mail to user list in admin panel

This commit is contained in:
onysd 2026-07-22 01:41:59 +03:00
parent f358d5a64a
commit 24276d1379
14 changed files with 301 additions and 10 deletions

View file

@ -46,6 +46,7 @@ type AccountRow struct {
PremiumUntil int64 PremiumUntil int64
LastActiveAt time.Time LastActiveAt time.Time
DeviceCount int DeviceCount int
LoginEmail string
} }
type AccountDetail struct { type AccountDetail struct {
@ -209,11 +210,13 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int, COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
COALESCE(ap.login_email, '')
FROM users u FROM users u
LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN auth a ON a.user_id = u.id LEFT JOIN auth a ON a.user_id = u.id
LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4 WHERE u.id = $1 OR u.phone = $2 OR u.phone = $3 OR lower(u.username) = $4 OR p.username_lower = $4
ORDER BY u.id ORDER BY u.id
LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit) LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
@ -224,7 +227,7 @@ LIMIT $5`, id, phone, phoneRaw, username, accountSearchLimit)
out := make([]AccountRow, 0) out := make([]AccountRow, 0)
for rows.Next() { for rows.Next() {
var item AccountRow var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil { if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, err return nil, err
} }
out = append(out, item) out = append(out, item)
@ -382,11 +385,13 @@ SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.upd
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
auth.last_active_at, auth.device_count, auth.last_active_at, auth.device_count,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
COALESCE(ap.login_email, '')
FROM users u FROM users u
JOIN auth ON auth.user_id = u.id JOIN auth ON auth.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id
LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE NOT u.is_bot WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
ORDER BY auth.last_active_at DESC, u.id DESC ORDER BY auth.last_active_at DESC, u.id DESC
@ -398,7 +403,7 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
out := make([]AccountRow, 0, limit+1) out := make([]AccountRow, 0, limit+1)
for rows.Next() { for rows.Next() {
var item AccountRow var item AccountRow
if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username); err != nil { if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil {
return nil, false, err return nil, false, err
} }
out = append(out, item) out = append(out, item)

View file

@ -51,6 +51,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession))) 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", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI))) 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", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI))) mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI))) mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
@ -347,6 +348,47 @@ func (s *server) handleAccountDetailAPI(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, detail) 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)
}
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil { if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title> <title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CRrNXtUN.js"></script> <script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7HtHaDP.css"> <link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
// Mirrors internal/web/server.go's publicAvatarGradients + initials() exactly,
// so admin-console avatars look identical to the public preview cards.
const AVATAR_GRADIENTS: [string, string][] = [
["#FF885E", "#FF516A"],
["#FFCD6A", "#FFA85C"],
["#82B1FF", "#665FFF"],
["#A0DE7E", "#54CB68"],
["#53EDD6", "#28C9B7"],
["#72D5FD", "#2A9EF1"],
["#E0A2F3", "#D669ED"]
];
function avatarGradient(id: number): [string, string] {
const n = Math.abs(id) % AVATAR_GRADIENTS.length;
return AVATAR_GRADIENTS[n];
}
function firstCodePoint(word: string): string {
const chars = Array.from(word);
return chars.length > 0 ? chars[0] : "";
}
function avatarInitials(firstName: string, lastName: string, username: string): string {
const title = `${firstName} ${lastName}`.trim();
const words = title.split(/\s+/).filter(Boolean);
const source = words.length > 0 ? words : (username ? [username] : []);
if (source.length === 0) return "T";
let out = firstCodePoint(source[0]);
if (source.length > 1) {
out += firstCodePoint(source[source.length - 1]);
}
return out.toUpperCase();
}
export function Avatar({
userID,
firstName,
lastName,
username = "",
size = 34
}: {
userID: number;
firstName: string;
lastName: string;
username?: string;
size?: number;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [userID]);
if (failed) {
const [from, to] = avatarGradient(userID);
return (
<div
className="avatar-fallback"
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
>
{avatarInitials(firstName, lastName, username)}
</div>
);
}
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar`}
alt=""
loading="lazy"
style={{ width: size, height: size }}
onError={() => setFailed(true)}
/>
);
}

View file

@ -106,6 +106,7 @@ const translations: Record<string, string> = {
"account.searchPlaceholder": "User ID / phone / username", "account.searchPlaceholder": "User ID / phone / username",
"account.userID": "User ID", "account.userID": "User ID",
"account.phone": "Phone", "account.phone": "Phone",
"account.loginEmail": "Login email",
"account.lastActive": "Last active", "account.lastActive": "Last active",
"account.notVerified": "Not verified", "account.notVerified": "Not verified",
"account.notPremium": "Not premium", "account.notPremium": "Not premium",

View file

@ -1,6 +1,7 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react"; import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format"; import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
@ -88,10 +89,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th className="avatar-col"></th>
<th>{t("account.userID")}</th> <th>{t("account.userID")}</th>
<th>{t("account.phone")}</th> <th>{t("account.phone")}</th>
<th>{t("common.username")}</th> <th>{t("common.username")}</th>
<th>{t("common.name")}</th> <th>{t("common.name")}</th>
<th>{t("account.loginEmail")}</th>
<th>{t("common.device")}</th> <th>{t("common.device")}</th>
<th>{t("account.lastActive")}</th> <th>{t("account.lastActive")}</th>
<th>{t("account.premium")}</th> <th>{t("account.premium")}</th>
@ -104,10 +107,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<tbody> <tbody>
{data?.rows.map((row) => ( {data?.rows.map((row) => (
<tr key={row.ID}> <tr key={row.ID}>
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td> <td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td> <td>{displayUsername(row.Username)}</td>
<td>{displayName(row)}</td> <td>{displayName(row)}</td>
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td>
<td>{row.DeviceCount}</td> <td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td> <td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td> <td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
@ -117,7 +122,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />} {(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -413,6 +413,22 @@
.gift-select-col { width: 34px; text-align: center; } .gift-select-col { width: 34px; text-align: center; }
.gift-select-col input { width: 15px; height: 15px; } .gift-select-col input { width: 15px; height: 15px; }
.avatar-col { width: 44px; }
.muted-cell { color: var(--muted); }
.avatar-photo-img,
.avatar-fallback {
display: block;
border-radius: 50%;
object-fit: cover;
}
.avatar-fallback {
display: grid;
place-items: center;
color: #ffffff;
font-weight: 800;
letter-spacing: -0.02em;
}
.gift-bulk-toolbar { .gift-bulk-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -12,6 +12,7 @@ export type AccountRow = {
PremiumUntil: number; PremiumUntil: number;
LastActiveAt: string; LastActiveAt: string;
DeviceCount: number; DeviceCount: number;
LoginEmail: string;
}; };
export type RestrictionRow = { export type RestrictionRow = {

View file

@ -884,6 +884,7 @@ func run(logger *zap.Logger) error {
ChannelNotifier: router, ChannelNotifier: router,
Messages: messagesService, Messages: messagesService,
Gifts: giftsService, Gifts: giftsService,
Photos: filesService,
}) })
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界), // bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。 // router 创建后注入。

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"net/http"
"net/url" "net/url"
"reflect" "reflect"
"sort" "sort"
@ -115,6 +116,14 @@ type OfficialGiftsSource interface {
Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error) Bundle(ctx context.Context, giftID int64, includeCollectible bool) (officialgifts.Bundle, error)
} }
// AvatarResolver is the same shape as internal/web's ProfilePhotoResolver, kept as its
// own local interface (rather than importing internal/web) since only this narrow slice
// is needed to serve an account's current profile photo in the admin console.
type AvatarResolver interface {
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
}
type Dependencies struct { type Dependencies struct {
Commands CommandRepository Commands CommandRepository
Restrictions RestrictionStore Restrictions RestrictionStore
@ -129,6 +138,7 @@ type Dependencies struct {
Messages MessagesService Messages MessagesService
Gifts GiftsService Gifts GiftsService
OfficialGifts OfficialGiftsSource OfficialGifts OfficialGiftsSource
Photos AvatarResolver
Now func() time.Time Now func() time.Time
} }
@ -146,6 +156,7 @@ type Service struct {
messages MessagesService messages MessagesService
gifts GiftsService gifts GiftsService
officialGifts OfficialGiftsSource officialGifts OfficialGiftsSource
photos AvatarResolver
now func() time.Time now func() time.Time
} }
@ -194,6 +205,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.OfficialGifts != nil { if deps.OfficialGifts != nil {
s.officialGifts = deps.OfficialGifts s.officialGifts = deps.OfficialGifts
} }
if deps.Photos != nil {
s.photos = deps.Photos
}
if deps.Now != nil { if deps.Now != nil {
s.now = deps.Now s.now = deps.Now
} }
@ -862,6 +876,106 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
return s.officialGifts.List(ctx) return s.officialGifts.List(ctx)
} }
const maxAccountAvatarBytes = 4 << 20
// AccountAvatar returns an account's current profile photo bytes and detected
// MIME type, mirroring internal/web's public avatar serving (same size
// selection and safe-image-type checks) so the admin console shows exactly
// what a public preview card would show for the same account.
func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error) {
if s == nil || s.photos == nil || userID <= 0 {
return nil, "", false, nil
}
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
if err != nil {
return nil, "", false, err
}
if !found {
return nil, "", false, nil
}
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
if !ok {
return nil, "", false, nil
}
data := inline
if len(data) == 0 {
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: maxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
}
if len(data) == 0 || len(data) > maxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
if !safeAccountImageType(detected) {
return nil, "", false, nil
}
return data, detected, true, nil
}
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
var (
best domain.PhotoSize
bestBytes []byte
bestScore int64 = -1
)
for _, size := range sizes {
if !validAccountPhotoSizeType(size.Type) {
continue
}
var inline []byte
switch size.Kind {
case domain.PhotoSizeKindCached:
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
continue
}
inline = size.Bytes
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
// Downloadable static raster size.
default:
continue
}
score := int64(size.W) * int64(size.H)
if score <= 0 {
score = int64(size.Size)
}
if score > bestScore {
best, bestBytes, bestScore = size, inline, score
}
}
return best, bestBytes, bestScore >= 0
}
func validAccountPhotoSizeType(value string) bool {
if value == "" || len(value) > 8 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func safeAccountImageType(value string) bool {
switch value {
case "image/jpeg", "image/png", "image/gif", "image/webp":
return true
default:
return false
}
}
func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) { func (s *Service) OfficialStarGiftAnimation(ctx context.Context, sourceGiftID string) ([]byte, bool, error) {
if s == nil || s.officialGifts == nil || s.gifts == nil { if s == nil || s.officialGifts == nil || s.gifts == nil {
return nil, false, officialgifts.ErrUnavailable return nil, false, officialgifts.ErrUnavailable

View file

@ -25,6 +25,7 @@ type Config struct {
} }
type Service interface { type Service interface {
AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error)
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error) GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error)
GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error) GrantStars(ctx context.Context, req admin.GrantStarsRequest) (admin.CommandResult, error)
@ -93,6 +94,7 @@ func (s *Server) routes() http.Handler {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}) })
mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen)) mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen))
mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar))
mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium)) mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium))
mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars)) mux.HandleFunc("POST /v1/accounts/grant-stars", s.authenticated(s.handleGrantStars))
mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified)) mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified))
@ -125,6 +127,28 @@ func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
} }
} }
func (s *Server) handleAccountAvatar(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || userID <= 0 {
http.NotFound(w, r)
return
}
data, mimeType, found, err := s.svc.AccountAvatar(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) { if !decodeJSON(w, r, &req) {

View file

@ -271,6 +271,10 @@ func (fakeService) ImportAllOfficialStarGifts(_ context.Context, req admin.Impor
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }
func (fakeService) AccountAvatar(context.Context, int64) ([]byte, string, bool, error) {
return nil, "", false, nil
}
func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) { func (fakeService) OfficialStarGifts(context.Context) ([]officialgifts.GiftSummary, error) {
return nil, nil return nil, nil
} }