added ability to change user info

This commit is contained in:
onysd 2026-08-05 21:19:50 +03:00
parent cddd341bb2
commit 40d49d5e97
22 changed files with 799 additions and 26 deletions

View file

@ -94,6 +94,10 @@ func (s *server) routes() http.Handler {
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-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
@ -1153,6 +1157,118 @@ func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
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"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-DAm5-kIE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D5eZOKdH.css">
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
</head>
<body>
<div id="root"></div>

View file

@ -230,6 +230,7 @@ export const api = {
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),

View file

@ -0,0 +1,88 @@
import { ImagePlus, Loader2, Upload, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// AccountAvatarModal uploads a new profile photo for an account. It follows
// CreateStickerSetModal's shape (a single reason field + direct execute,
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
// a multipart file upload, not a plain JSON body.
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!file) {
setPreviewURL("");
return;
}
const url = URL.createObjectURL(file);
setPreviewURL(url);
return () => URL.revokeObjectURL(url);
}, [file]);
async function submit() {
if (!file) {
setError("Choose an image file first.");
return;
}
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
form.set("file", file, file.name);
const result = await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Account"}</div>
<h2>{"Change avatar"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{"Upload avatar"}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -39,19 +39,24 @@ export function Avatar({
firstName,
lastName,
username = "",
size = 34
size = 34,
refreshKey
}: {
userID: number;
firstName: string;
lastName: string;
username?: string;
size?: number;
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
// right after an admin-driven avatar change, so the new photo shows up
// immediately instead of the stale cached one.
refreshKey?: number | string;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [userID]);
}, [userID, refreshKey]);
if (failed) {
const [from, to] = avatarGradient(userID);
@ -68,7 +73,7 @@ export function Avatar({
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar`}
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
alt=""
loading="lazy"
style={{ width: size, height: size }}

View file

@ -1,4 +1,4 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { AtSign, LifeBuoy, Mail, Palette, Phone, Settings2, Smile, UserRound } from "lucide-react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { toInt } from "../lib/format";
@ -47,6 +47,92 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
);
}
// ProfileNameAction force-sets a user's first and last name.
export function ProfileNameAction({ id, path, currentFirstName, currentLastName, onDone }: {
id: number;
path: string;
currentFirstName: string;
currentLastName: string;
onDone: () => void;
}) {
const [firstName, setFirstName] = useState(currentFirstName);
const [lastName, setLastName] = useState(currentLastName);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"First name"}</span>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
</label>
<label className="duration-field">
<span>{"Last name"}</span>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
</label>
<ActionButton
label={"Set name"}
icon={<UserRound size={15} />}
tone="neutral"
path={path}
payload={() => ({ user_id: id, first_name: firstName.trim(), last_name: lastName.trim() })}
onDone={onDone}
/>
</div>
);
}
// PhoneAction force-sets a user's phone number. The backend rejects a value
// already tied to another account.
export function PhoneAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [phone, setPhone] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Phone number"}</span>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="15551234567" />
</label>
<ActionButton
label={"Set phone"}
icon={<Phone size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, phone: phone.trim() })}
onDone={onDone}
/>
</div>
);
}
// LoginEmailAction sets, or (left empty) clears, the login/signup email
// factor. The backend rejects an email already tied to another account.
export function LoginEmailAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [email, setEmail] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Login email"}</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com (empty clears it)" type="email" />
</label>
<ActionButton
label={email.trim() ? "Set login email" : "Clear login email"}
icon={<Mail size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, email: email.trim() })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;

View file

@ -1,12 +1,13 @@
import { ArrowLeft, BadgeCheck, CircleAlert, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { AccountAvatarModal } from "../components/AccountAvatarModal";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary, UsernameCell } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { ColorAction, EmojiStatusAction, LoginEmailAction, PhoneAction, ProfileNameAction, SupportAction, UsernameAction } from "../components/attributes";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
@ -22,6 +23,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
const [freezeAppealURL, setFreezeAppealURL] = useState("");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
@ -70,7 +73,18 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
>
<section className="entity-head">
<div className="entity-head-main">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} />
<div className="avatar-edit-slot">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change avatar"}
title={"Change avatar"}
onClick={() => setAvatarModalOpen(true)}
>
<ImagePlus size={13} />
</button>
</div>
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
@ -257,6 +271,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Name"} />
<ProfileNameAction id={account.ID} path="/api/actions/set-account-profile" currentFirstName={account.FirstName} currentLastName={account.LastName} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Phone Number"} />
<PhoneAction id={account.ID} path="/api/actions/set-account-phone" current={account.Phone} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Login Email"} text={"The email used for sign-in / password-recovery, not a contact address."} />
<LoginEmailAction id={account.ID} path="/api/actions/set-account-login-email" current={account.LoginEmail} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
@ -274,6 +303,17 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</section>
</div>
)}
{avatarModalOpen && (
<AccountAvatarModal
userID={account.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -33,6 +33,25 @@
flex-shrink: 0;
}
.avatar-edit-slot {
position: relative;
flex-shrink: 0;
}
.avatar-edit-btn {
position: absolute;
right: -4px;
bottom: -4px;
width: 24px;
height: 24px;
padding: 0;
color: var(--brand);
background: var(--panel);
border: 1px solid var(--line-strong);
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .2);
}
.avatar-edit-btn:hover { background: var(--brand-tint); border-color: var(--brand); }
.entity-title {
color: var(--heading);
font-size: 20px;

View file

@ -1413,6 +1413,7 @@ func run(logger *zap.Logger) error {
Rating: ratingService,
Verification: verificationService,
BotVerification: botVerificationService,
Account: accountService,
})
// The RPC edge owns the tg.* projection cache and the standard non-PTS
// updateUser/updateChannel refresh, so committed registry mutations are

View file

@ -33,6 +33,10 @@ const (
ActionSetUsername = "account.set_username"
ActionSetUserColor = "account.set_color"
ActionSetUserEmojiStatus = "account.set_emoji_status"
ActionSetProfile = "account.set_profile"
ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar"
ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings"
ActionSetChannelColor = "channel.set_color"
@ -209,6 +213,18 @@ type UsersService interface {
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
// SetPhone force-sets a user's phone number (no code verification).
SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error)
}
// AccountService carries the login-email factor (account_passwords table),
// a separate concern from UsersService's users-table fields.
type AccountService interface {
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
SetLoginEmail(ctx context.Context, userID int64, email string) error
// ClearLoginEmail removes the login email factor entirely.
ClearLoginEmail(ctx context.Context, userID int64) error
}
type StarsService interface {
@ -281,6 +297,11 @@ type OfficialGiftsSource interface {
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)
// ValidateAvatarUpload is a pure check (no store writes), used by a dry-run
// preview before CreateAvatarFromBytes actually materializes the avatar.
ValidateAvatarUpload(data []byte) bool
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
}
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
@ -393,7 +414,10 @@ type Dependencies struct {
// BotVerification is the third-party mechanism, wired separately from
// Verification: the two never read each other's state.
BotVerification BotVerificationService
Now func() time.Time
// Account carries the login-email factor -- a separate app service from
// Users, since login email lives in account_passwords, not users.
Account AccountService
Now func() time.Time
}
type Service struct {
@ -422,6 +446,7 @@ type Service struct {
rating AccountRatingService
verification VerificationService
botVerification BotVerificationService
account AccountService
now func() time.Time
}
@ -506,6 +531,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
if deps.BotVerification != nil {
s.botVerification = deps.BotVerification
}
if deps.Account != nil {
s.account = deps.Account
}
if deps.Now != nil {
s.now = deps.Now
}
@ -889,6 +917,37 @@ type SetUsernameRequest struct {
Username string `json:"username"`
}
// SetProfileRequest updates first/last name. Both are always sent (not
// pointer/omitempty): the admin form always shows and submits both fields
// together, so there is no "leave unset" case to represent here.
type SetProfileRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
type SetPhoneRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Phone string `json:"phone"`
}
// SetLoginEmailRequest force-sets (or, if Email is empty, clears) a user's
// login/signup email.
type SetLoginEmailRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
Email string `json:"email"`
}
type SetAccountAvatarRequest struct {
CommandMeta
UserID int64 `json:"user_id"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
}
type SetChannelUsernameRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
@ -1594,6 +1653,151 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm
})
}
// SetProfile force-sets a user's first and last name.
func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
firstName := strings.TrimSpace(req.FirstName)
lastName := strings.TrimSpace(req.LastName)
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
"new_first_name": firstName, "new_last_name": lastName,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
FirstName: firstName, HasFirstName: true,
LastName: lastName, HasLastName: true,
})
if err != nil {
return CommandResult{}, err
}
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "profile updated", Details: details}, nil
})
}
// SetPhone force-sets a user's phone number. Rejects a collision with
// another account's phone (checked by the users service before writing,
// backed by the users_phone_unique_idx constraint as well).
func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
phone := strings.TrimSpace(req.Phone)
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
if err != nil {
return CommandResult{}, err
}
details["updated_phone"] = updated.Phone
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "phone updated", Details: details}, nil
})
}
// SetLoginEmail force-sets (or, if Email is empty, clears) a user's
// login/signup email. Rejects a collision with another account's login
// email (checked by the account service before writing, backed by the
// account_passwords_login_email_lower_unique_idx constraint as well).
func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
}
email := strings.TrimSpace(req.Email)
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"new_login_email": email}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
var err error
if email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, email)
}
if err != nil {
return CommandResult{}, err
}
message := "login email updated"
if email == "" {
message = "login email cleared"
}
return CommandResult{Message: message, Details: details}, nil
})
}
// SetAccountAvatar force-sets a user's current profile photo from raw
// uploaded image bytes, reusing the same avatar rendition pipeline
// (s/a/c sizes) as photos.uploadProfilePhoto.
func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequest) (CommandResult, error) {
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
if req.DryRun {
return CommandResult{Message: "avatar validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
if err != nil {
return CommandResult{Details: details}, err
}
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
if s.users != nil {
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
details["notify_error"] = nerr.Error()
}
}
}
return CommandResult{Message: "avatar updated", Details: details}, nil
})
}
// SetUserColor force-sets or clears a user's name/profile color.
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
if req.UserID <= 0 {
@ -2649,7 +2853,9 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
return s.officialGifts.List(ctx)
}
const maxAccountAvatarBytes = 4 << 20
// MaxAccountAvatarBytes bounds both reading (AccountAvatar) and writing
// (SetAccountAvatar) a user's profile photo through the admin console.
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
@ -2674,17 +2880,17 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
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,
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
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 {
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
@ -2707,7 +2913,7 @@ func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, b
var inline []byte
switch size.Kind {
case domain.PhotoSizeKindCached:
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
if len(size.Bytes) == 0 || len(size.Bytes) > MaxAccountAvatarBytes {
continue
}
inline = size.Bytes

View file

@ -835,6 +835,39 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
return u, nil
}
func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
if update.HasFirstName {
u.FirstName = update.FirstName
}
if update.HasLastName {
u.LastName = update.LastName
}
if update.HasAbout {
u.About = update.About
}
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) SetPhone(_ context.Context, userID int64, phone string) (domain.User, error) {
u, ok := f.users[userID]
if !ok {
return domain.User{}, domain.ErrUserNotFound
}
for id, existing := range f.users {
if id != userID && existing.Phone == phone {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
}
u.Phone = phone
f.users[userID] = u
return u, nil
}
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
u, ok := f.users[userID]
if !ok {

View file

@ -53,6 +53,10 @@ type Service interface {
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
SetProfile(ctx context.Context, req admin.SetProfileRequest) (admin.CommandResult, error)
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
@ -192,6 +196,10 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
mux.HandleFunc("POST /v1/accounts/set-profile", s.authenticated(s.handleSetProfile))
mux.HandleFunc("POST /v1/accounts/set-phone", s.authenticated(s.handleSetPhone))
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail))
mux.HandleFunc("POST /v1/accounts/set-avatar", s.authenticated(s.handleSetAccountAvatar))
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
@ -384,6 +392,67 @@ func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
writeCommandResult(w, result, err)
}
func (s *Server) handleSetProfile(w http.ResponseWriter, r *http.Request) {
var req admin.SetProfileRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetProfile(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetPhone(w http.ResponseWriter, r *http.Request) {
var req admin.SetPhoneRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetPhone(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
var req admin.SetLoginEmailRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.SetLoginEmail(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetAccountAvatar(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 {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var req admin.SetAccountAvatarRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(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 {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req.FileName = header.Filename
req.Data = data
result, err := s.svc.SetAccountAvatar(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
var req admin.SetUserColorRequest
if !decodeJSON(w, r, &req) {

View file

@ -483,6 +483,22 @@ func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest)
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetProfile(_ context.Context, req admin.SetProfileRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetPhone(_ context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetLoginEmail(_ context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -140,6 +140,28 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
return s.media.GetDocument(ctx, id)
}
// ValidateAvatarUpload is a pure check (decodes only the image header) so a
// dry-run preview can validate bytes before SetAccountAvatar/CreateAvatarFromBytes
// actually renders and stores the avatar's s/a/c size set.
func (s *Service) ValidateAvatarUpload(data []byte) bool {
if len(data) == 0 {
return false
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
return err == nil && cfg.Width > 0 && cfg.Height > 0
}
// CreateAvatarFromBytes stores already-in-hand image bytes as an avatar Photo
// ('s'/'a'/'c' sizes), for callers that skip the chunked upload.saveFilePart
// transfer regular clients use (e.g. the admin console, which already has the
// full file from a browser upload).
func (s *Service) CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error) {
if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid
}
return s.createAvatarPhoto(ctx, data, ownerUserID)
}
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo's'/'a'/'c' 尺寸,'a'/'c' 匹配
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {

View file

@ -253,6 +253,35 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
return s.projectOne(ctx, self.ID, u)
}
// SetPhone force-sets a user's phone number (admin use -- no code
// verification, unlike the user-facing verified change-phone flow in
// internal/app/account). Pre-checks availability via ByPhone before writing,
// on top of the store's own unique-constraint backstop.
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
}
phone = domain.NormalizePhone(strings.TrimSpace(phone))
if !domain.ValidPhone(phone) {
return domain.User{}, domain.ErrPhoneNumberInvalid
}
if phone == self.Phone {
return s.projectOne(ctx, self.ID, self)
}
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
return domain.User{}, err
} else if found && existing.ID != self.ID {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
if err != nil {
return domain.User{}, err
}
s.refreshCachedUsers(ctx, u)
return s.projectOne(ctx, self.ID, u)
}
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)

View file

@ -245,6 +245,25 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
return u, nil
}
func (s *UserStore) UpdatePhone(_ context.Context, userID int64, phone string) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[userID]
if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound
}
if phone != "" {
for id, existing := range s.byID {
if id != userID && existing.Phone == phone {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
}
}
u.Phone = phone
s.byID[userID] = u
return u, nil
}
func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -220,6 +220,26 @@ func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName,
return userFromModel(row), nil
}
// UpdatePhone force-sets a user's phone number. Used only by the admin
// panel -- the user-facing change-phone flow (internal/app/account) requires
// a verified code and lives in internal/store/postgres/phone_change.go.
func (s *UserStore) UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
row, err := s.q.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{
ID: userID,
Phone: phone,
})
if err != nil {
if isUniqueConstraint(err, "users_phone_unique_idx") {
return domain.User{}, domain.ErrPhoneNumberOccupied
}
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("update user phone: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)

View file

@ -20,6 +20,9 @@ type UserStore interface {
Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error)
UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
// UpdatePhone force-sets a user's phone number (admin use -- no code
// verification, unlike the user-facing verified change-phone flow).
UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error)
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
// Create 创建用户并返回分配了 ID 的副本。
Create(ctx context.Context, u domain.User) (domain.User, error)