updated bots,channels and supergroups lists and edit screens

This commit is contained in:
onysd 2026-08-05 21:49:30 +03:00
parent 40d49d5e97
commit 0b3c861057
21 changed files with 796 additions and 198 deletions

View file

@ -58,6 +58,7 @@ func (s *server) routes() http.Handler {
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)))
@ -100,6 +101,7 @@ func (s *server) routes() http.Handler {
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)))
@ -709,6 +711,90 @@ func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request)
_, _ = 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")

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,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
<script type="module" crossorigin src="/assets/index-DZsC6sWi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
</head>
<body>

View file

@ -231,6 +231,7 @@ export const api = {
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 }),
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-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

@ -34,18 +34,29 @@ function avatarInitials(firstName: string, lastName: string, username: string):
return out.toUpperCase();
}
type AvatarKind = "user" | "channel";
// Avatar renders a user's or channel's current photo, reading through the
// admin console's proxy (/api/accounts/{id}/avatar or /api/channels/{id}/avatar).
// It falls back to a gradient-tinted initials tile — identical to the public
// preview cards — when there's no photo or the fetch fails.
export function Avatar({
userID,
firstName,
lastName,
id,
kind = "user",
firstName = "",
lastName = "",
username = "",
title = "",
size = 34,
refreshKey
}: {
userID: number;
firstName: string;
lastName: string;
id: number;
kind?: AvatarKind;
firstName?: string;
lastName?: string;
username?: string;
// title is the channel/supergroup name, used for initials when kind="channel".
title?: 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
@ -56,24 +67,25 @@ export function Avatar({
useEffect(() => {
setFailed(false);
}, [userID, refreshKey]);
}, [id, kind, refreshKey]);
if (failed) {
const [from, to] = avatarGradient(userID);
const [from, to] = avatarGradient(id);
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)}
{kind === "channel" ? avatarInitials(title, "", username) : avatarInitials(firstName, lastName, username)}
</div>
);
}
const basePath = kind === "channel" ? `/api/channels/${id}/avatar` : `/api/accounts/${id}/avatar`;
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
src={`${basePath}${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
alt=""
loading="lazy"
style={{ width: size, height: size }}

View file

@ -4,11 +4,13 @@ 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 }) {
type AvatarModalKind = "user" | "channel";
// AvatarModal uploads a new photo for an account or a channel/supergroup. 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 AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
@ -37,10 +39,11 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
setBusy(true);
setError("");
try {
const idField = kind === "channel" ? "channel_id" : "user_id";
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
form.set("file", file, file.name);
const result = await api.setAccountAvatar(form);
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
@ -54,12 +57,14 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
}
}
const noun = kind === "channel" ? "Channel" : "Account";
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>
<div className="eyebrow">{noun}</div>
<h2>{"Change avatar"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>

View file

@ -1,7 +1,7 @@
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 { AvatarModal } from "../components/AvatarModal";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable";
@ -74,7 +74,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
<Avatar id={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"
@ -305,8 +305,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
)}
{avatarModalOpen && (
<AccountAvatarModal
userID={account.ID}
<AvatarModal
kind="user"
id={account.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);

View file

@ -166,7 +166,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>

View file

@ -1,18 +1,25 @@
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotDetail } from "../types";
type Tab = "profile" | "actions";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
@ -28,6 +35,8 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
useEffect(() => {
void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (error) {
@ -38,26 +47,59 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
}
const bot = detail.Bot;
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
];
return (
<PageFrame
title={`Bot #${bot.ID}`}
eyebrow={"Bot Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar id={bot.ID} firstName={bot.FirstName} username={bot.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">{bot.FirstName || "Unnamed bot"}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
</div>
</div>
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div>
</section>
<div className="toolbar" role="group" aria-label={"Bot sections"}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "profile" && (
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
@ -67,15 +109,14 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Bot Actions"}</div>
)}
{tab === "actions" && (
<div className="stacked-sections">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Verification & Moderation Flags"} />
<div className="action-stack">
<ActionButton
label={bot.Verified ? "Clear verified" : "Set verified"}
@ -87,10 +128,25 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{"Attributes"}</div>
</section>
<section className="section-block">
<SectionHead title={"Username"} />
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Emoji Status"} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Danger Zone"} />
{bot.System ? (
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
) : (
@ -107,8 +163,26 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
</div>
)}
</section>
}
</div>
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
)}
{avatarModalOpen && (
<AvatarModal
kind="user"
id={bot.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -1,18 +1,28 @@
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { BadgeCheck, Bot, ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotListResponse } from "../types";
type Cursor = { beforeID: number };
type BotPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0 };
export function BotsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [limit, setLimit] = useState<BotPageSize>(50);
const [data, setData] = useState<BotListResponse | null>(null);
const [cursor, setCursor] = useState(0);
// history holds the cursor used to reach every page before the current
// one, so "Previous" can pop back without re-deriving offsets -- keyset
// pagination has no notion of "page N" to jump back to otherwise.
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@ -20,40 +30,71 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
const [botName, setBotName] = useState("");
const [botUsername, setBotUsername] = useState("");
async function load(next = false) {
async function fetchPage(query: string, at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) {
params.set("q", query.trim());
}
if (at.beforeID) {
params.set("before_id", String(at.beforeID));
}
try {
const result = await api.bots(params);
setData(result);
setCursor(result.next_before_id);
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(q, zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id };
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void load(false);
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = data?.rows ?? [];
const verified = rows.filter((row) => row.Verified).length;
const systemCount = rows.filter((row) => row.System).length;
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Bots"}
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
@ -104,29 +145,35 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
onDone={() => load(false)}
onDone={() => void loadFresh()}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
</label>
<label className="field-inline">
<label className="gift-page-size">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as BotPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {"Next page"}
</button>
)}
</form>
</QueryPanel>
@ -134,6 +181,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"Bot ID"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
@ -147,6 +195,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} username={row.Username} /></td>
<td className="mono">{row.ID}</td>
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
@ -157,7 +206,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>

View file

@ -1,53 +1,82 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
type Tab = "profile" | "actions";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading channel detail"} />;
return <LoadingSurface label={busy ? "Loading channel detail" : "Waiting for data"} />;
}
const ch = detail.Channel;
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
];
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow={"Channel Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar id={ch.ID} kind="channel" title={ch.Title} 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">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
@ -55,6 +84,23 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
</div>
</section>
<div className="toolbar" role="group" aria-label={"Channel sections"}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "profile" && (
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
@ -66,19 +112,19 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Channel Actions"}</div>
)}
{tab === "actions" && (
<div className="stacked-sections">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Verification & Moderation Flags"} />
<div className="action-stack">
<ActionButton
label={ch.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
@ -88,15 +134,48 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{"Settings"}</div>
</div>
</section>
<section className="section-block">
<SectionHead title={"Settings"} />
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{"Attributes"}</div>
</section>
<section className="section-block">
<SectionHead title={"Username"} />
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Emoji Status"} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
</section>
}
</div>
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
)}
{avatarModalOpen && (
<AvatarModal
kind="channel"
id={ch.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -1,6 +1,7 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { channelKind, displayUsername, formatDate } from "../lib/format";
@ -8,50 +9,87 @@ import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
type Cursor = { beforeID: number; beforeUpdatedUS: number };
type ChannelPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0, beforeUpdatedUS: 0 };
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [limit, setLimit] = useState<ChannelPageSize>(50);
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
// history holds the cursor used to reach every page before the current
// one, so "Previous" can pop back without re-deriving offsets -- keyset
// pagination has no notion of "page N" to jump back to otherwise.
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
async function fetchPage(query: string, at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) {
params.set("q", query.trim());
}
if (at.beforeID || at.beforeUpdatedUS) {
params.set("before_id", String(at.beforeID));
params.set("before_updated_us", String(at.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(q, zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id, beforeUpdatedUS: data.next_before_updated_us };
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void load(false);
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const metrics = channelMetrics(data?.rows ?? []);
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Supergroups and Channels"}
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
@ -64,29 +102,36 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
</label>
<label className="field-inline">
<label className="gift-page-size">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as ChannelPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {"Next page"}
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"Channel ID"}</th>
<th>{"Kind"}</th>
<th>{"Username"}</th>
@ -102,6 +147,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar id={row.ID} kind="channel" title={row.Title} /></td>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
@ -114,7 +160,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>

View file

@ -37,6 +37,7 @@ const (
ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar"
ActionSetChannelAvatar = "channel.set_avatar"
ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings"
ActionSetChannelColor = "channel.set_color"
@ -255,6 +256,7 @@ type ChannelsService interface {
AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error)
AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error)
}
type ChannelNotifier interface {
@ -302,6 +304,10 @@ type AvatarResolver interface {
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)
// GetPhoto looks up a photo by id directly -- used to read a channel's
// current avatar, which is denormalized on the channel row as a bare
// photo_id rather than tracked through CurrentProfilePhotoKind.
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
}
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
@ -948,6 +954,13 @@ type SetAccountAvatarRequest struct {
Data []byte `json:"-"`
}
type SetChannelAvatarRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
}
type SetChannelUsernameRequest struct {
CommandMeta
ChannelID int64 `json:"channel_id"`
@ -1798,6 +1811,97 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
})
}
// SetChannelAvatar force-sets a channel's avatar from raw uploaded image
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
// SetAccountAvatar, but attaching the resulting photo directly to the
// channel row (photo_id) through the permission-check-free admin path
// instead of profile_photos history.
func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequest) (CommandResult, error) {
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
}
if s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, 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, 0)
if err != nil {
return CommandResult{Details: details}, err
}
updated, err := s.channels.AdminSetPhoto(ctx, req.ChannelID, photo)
if err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
if err := s.notifyChannelChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
return CommandResult{Message: "avatar updated", Details: details}, nil
})
}
// ChannelAvatar returns a channel's current avatar bytes and detected MIME
// type. Unlike a user's profile photo (tracked via profile_photos history),
// a channel's current photo is denormalized directly on the channel row as
// photo_id, so this resolves that id through GetPhoto instead of
// CurrentProfilePhotoKind.
func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error) {
if s == nil || s.photos == nil || s.channels == nil || channelID <= 0 {
return nil, "", false, nil
}
channel, err := s.channels.GetChannelByID(ctx, channelID)
if err != nil {
return nil, "", false, err
}
if channel.PhotoID == 0 {
return nil, "", false, nil
}
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
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
}
// 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 {

View file

@ -1034,6 +1034,16 @@ func (f *fakeChannelsService) AdminSetEmojiStatus(_ context.Context, channelID i
return ch, nil
}
func (f *fakeChannelsService) AdminSetPhoto(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
ch, ok := f.channels[channelID]
if !ok {
return domain.Channel{}, domain.ErrChannelInvalid
}
ch.PhotoID = photo.ID
f.channels[channelID] = ch
return ch, nil
}
type fakeChannelNotifier struct {
channels []int64
}

View file

@ -57,6 +57,8 @@ type Service interface {
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)
ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error)
SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (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)
@ -203,6 +205,8 @@ func (s *Server) routes() http.Handler {
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))
mux.HandleFunc("GET /v1/channels/{id}/avatar", s.authenticated(s.handleChannelAvatar))
mux.HandleFunc("POST /v1/channels/set-avatar", s.authenticated(s.handleSetChannelAvatar))
mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified))
mux.HandleFunc("POST /v1/channels/set-flags", s.authenticated(s.handleSetChannelFlags))
mux.HandleFunc("POST /v1/channels/set-settings", s.authenticated(s.handleSetChannelSettings))
@ -453,6 +457,62 @@ func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request)
writeCommandResult(w, result, err)
}
func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || channelID <= 0 {
http.NotFound(w, r)
return
}
data, mimeType, found, err := s.svc.ChannelAvatar(r.Context(), channelID)
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) handleSetChannelAvatar(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.SetChannelAvatarRequest
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.SetChannelAvatar(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

@ -499,6 +499,14 @@ func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvata
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) ChannelAvatar(_ context.Context, _ int64) ([]byte, string, bool, error) {
return nil, "", false, nil
}
func (fakeService) SetChannelAvatar(_ context.Context, req admin.SetChannelAvatarRequest) (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

@ -558,6 +558,15 @@ func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, stat
return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
}
// AdminSetPhoto force-sets a channel's avatar through the admin path (no
// permission checks, no "changed photo" service message).
func (s *Service) AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
return s.channels.SetChannelPhotoAdmin(ctx, channelID, photo)
}
// ListAdminedPublicChannels returns public channels/supergroups administered by user.
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
if s == nil || s.channels == nil || userID == 0 {

View file

@ -40,6 +40,10 @@ type ChannelStore interface {
SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error)
SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
// SetChannelPhotoAdmin force-sets a channel's avatar with no permission
// checks and no service message (unlike SetChannelPhoto, which requires an
// acting admin member and posts a "changed photo" service message).
SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)

View file

@ -309,6 +309,27 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID i
return cloneChannel(channel), nil
}
func (s *ChannelStore) SetChannelPhotoAdmin(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ErrChannelInvalid
}
stripped := domain.StrippedFromSizes(photo.Sizes)
if stripped == nil {
stripped = []byte{}
}
channel.PhotoID = photo.ID
channel.PhotoDCID = photo.DCID
channel.PhotoStripped = stripped
s.channels[channelID] = channel
return cloneChannel(channel), nil
}
func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
_ = viewerUserID // zero is the anonymous public-web view; no membership state is projected.
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))

View file

@ -497,6 +497,35 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID
return channel, nil
}
// SetChannelPhotoAdmin force-sets a channel's avatar with no permission
// checks and no "changed photo" service message — unlike SetChannelPhoto,
// which requires an acting admin member and broadcasts to the channel's
// timeline. Mirrors SetChannelColorAdmin/SetChannelEmojiStatusAdmin's shape.
func (s *ChannelStore) SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
if channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
if err != nil {
return domain.Channel{}, err
}
stripped := domain.StrippedFromSizes(photo.Sizes)
if stripped == nil {
stripped = []byte{}
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`,
channelID, photo.ID, photo.DCID, stripped); err != nil {
return domain.Channel{}, fmt.Errorf("set channel photo admin: %w", err)
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
channel.PhotoID = photo.ID
channel.PhotoDCID = photo.DCID
channel.PhotoStripped = stripped
return channel, nil
}
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))