fixes and improvements for new server settings menu

This commit is contained in:
onysd 2026-08-26 00:37:25 +03:00
parent 902f3606c2
commit 66f9c0bc1e
27 changed files with 2244 additions and 40 deletions

View file

@ -27,6 +27,23 @@ const hostStatsPollInterval = 5 * time.Second
const defaultAdminAPIAddr = "127.0.0.1:2599"
// bootID is a random value generated once per process start, exposed via
// GET /api/session -- see that handler's doc comment for why (the
// Restart/Update polling flow's way of detecting a genuinely new admin
// process, not just a slow-to-respond old one).
var bootID = newBootID()
func newBootID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
// crypto/rand failing is effectively unheard of on any real target
// this binary runs on; falling back to the wall clock still gives a
// value that changes across restarts, which is all this is for.
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
@ -95,6 +112,18 @@ type uiConfig struct {
// permissions, and the session/login response tells the frontend to hide
// the "Third-party marks" nav entry and its routes.
HideThirdPartyVerification bool
// IdentityDir mirrors config.IdentityDir -- must point at the same
// directory owpengram-server reads, so an identity edit here is visible
// over /owpengram/server-info immediately (see internal/identity).
IdentityDir string
// RepoRoot is where Server Settings' Restart/Update/.env-editing (see
// internal/procctl) operate: bin/, logs/, .env, .env.example and
// .server_panel.json are all expected directly under it, exactly as
// tui-panel/server-panel.py expects. Defaults to the process's current
// working directory, which is correct whenever this binary is launched
// from (or by something that cd'd into) the repo root -- true both for a
// manual run and for how the TUI itself launches it.
RepoRoot string
}
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
@ -121,6 +150,11 @@ func loadConfig() (uiConfig, error) {
}
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
repoRoot, err := os.Getwd()
if err != nil {
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
}
return uiConfig{
Addr: appCfg.AdminUIAddr,
PostgresDSN: appCfg.PostgresDSN,
@ -132,6 +166,8 @@ func loadConfig() (uiConfig, error) {
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
BlobDir: appCfg.BlobDir,
IdentityDir: appCfg.IdentityDir,
RepoRoot: repoRoot,
}, nil
}

View file

@ -45,6 +45,13 @@ const (
// curates the icon catalogue and strips granted marks.
permissionBotVerificationReview = "botverification.review"
permissionBotVerificationManage = "botverification.manage"
// permissionServerManage gates the whole Server Settings panel: identity
// (name/description/icon), .env editing, and Restart/Update -- all of it
// meaningfully more sensitive than any domain-data action above (.env
// editing exposes every secret the deployment holds; Restart/Update runs
// git/go and bounces the live MTProto process), so it is one right, not
// split into review/manage like the sections above.
permissionServerManage = "server.manage"
)
type permissionsKey struct{}

View file

@ -20,6 +20,8 @@ import (
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/hoststats"
"telesrv/internal/identity"
"telesrv/internal/procctl"
)
//go:embed web/dist
@ -31,6 +33,8 @@ type server struct {
hostStats *hoststats.Poller
web fs.FS
webServer http.Handler
identity *identity.Store
serverCtl *procctl.Manager
}
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
@ -44,6 +48,8 @@ func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*ser
hostStats: hostStats,
web: web,
webServer: http.FileServer(http.FS(web)),
identity: identity.NewStore(cfg.IdentityDir),
serverCtl: procctl.NewManager(cfg.RepoRoot),
}, nil
}
@ -163,6 +169,20 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
// Server Settings -- see serversettings.go. Everything here operates
// directly on local files/processes (no RPC hop to owpengram-server's
// in-process admin API), so it works even for actions (Restart/Update)
// that owpengram-server could never safely perform on itself.
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
mux.Handle("POST /api/actions/restart-server", s.serverManage(s.handleRestartServerAPI))
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found")
})
@ -274,6 +294,12 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
"actor": actorFromContext(r.Context()),
"permissions": permissionsFromContext(r.Context()).List(),
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
// boot_id is random per process start (see main.go) -- Server
// Settings' Restart/Update flow polls this after triggering an
// action and reloads the page once it changes, which is how it
// tells "the old admin process died and a new one answered" apart
// from "the old one is just slow to respond".
"boot_id": bootID,
})
}

View file

@ -0,0 +1,264 @@
package main
import (
"encoding/json"
"io"
"net/http"
"path/filepath"
"strings"
"telesrv/internal/admin"
)
// serverManage gates the whole Server Settings surface -- see
// permissionServerManage's doc comment in security.go for why this is one
// right rather than split review/manage like other sections.
func (s *server) serverManage(handler http.HandlerFunc) http.Handler {
return s.requireAuthAPI(s.requirePermission(permissionServerManage, handler))
}
// serverCommandResult builds the same admin.CommandResult shape every other
// action returns, without going through internal/admin's runCommand +
// Postgres audit log: everything in this file operates on local files/
// processes directly (see routes() in server.go for why), so there is no
// owpengram-server-side admin_commands row to write. The actor/reason are
// still in meta for structured logging if that's ever added; today they are
// simply not persisted anywhere.
func serverCommandResult(meta admin.CommandMeta, action string, err error, message string, details map[string]any) admin.CommandResult {
status := "completed"
errText := ""
if err != nil {
status = "failed"
errText = err.Error()
if message == "" {
message = "command failed"
}
}
return admin.CommandResult{
CommandID: meta.CommandID,
Action: action,
Status: status,
DryRun: meta.DryRun,
Message: message,
Details: details,
Error: errText,
}
}
// --- identity (name/description/icon) ---------------------------------
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
info, err := s.identity.Get()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, info)
}
// handleServerIconAPI serves the icon's raw bytes for the panel's own
// preview -- separate from owpengram-server's public /owpengram/server-icon
// (same underlying file, different process/auth: this one is behind the
// admin session, not open to clients).
func (s *server) handleServerIconAPI(w http.ResponseWriter, r *http.Request) {
data, ext, ok := s.identity.Icon()
if !ok {
writeAPIError(w, http.StatusNotFound, "no icon configured")
return
}
contentType := map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".gif": "image/gif",
}[ext]
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
type setServerIdentityAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Name string `json:"name"`
Description string `json:"description"`
}
func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
var body setServerIdentityAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-server-identity")
details := map[string]any{"name": body.Name, "description": body.Description}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", nil, "server identity validated", details))
return
}
err := s.identity.SetText(body.Name, body.Description)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
}
var allowedServerIconExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
}
const maxServerIconBytes = 2 << 20 // 2 MiB
type uploadServerIconAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
// handleUploadServerIconAPI takes multipart/form-data (a "metadata" JSON
// field + a "file" field), the same shape handleSetAccountAvatarAPI uses --
// deliberately not JSON+base64 like the other Server Settings actions:
// base64 inflates a file ~33%, and decodeAction's plain io.LimitReader caps
// the request body at 1MiB regardless of maxServerIconBytes, so a real
// multi-hundred-KB icon would fail decoding ("unexpected EOF" from the
// truncated body) before this handler ever saw it. Multipart sidesteps that
// entirely -- the size cap below is enforced on the actual file bytes.
func (s *server) handleUploadServerIconAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, maxServerIconBytes+(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 uploadServerIconAPIRequest
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, "icon file is required")
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if !allowedServerIconExts[ext] {
writeAPIError(w, http.StatusBadRequest, "unsupported icon extension")
return
}
data, err := io.ReadAll(io.LimitReader(file, maxServerIconBytes+1))
if err != nil || len(data) == 0 || len(data) > maxServerIconBytes {
writeAPIError(w, http.StatusBadRequest, "icon file is empty or too large (max 2MiB)")
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upload-server-icon")
details := map[string]any{"bytes": len(data), "ext": ext}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", nil, "server icon validated", details))
return
}
setErr := s.identity.SetIcon(data, ext)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", setErr, "server icon updated", details))
}
type removeServerIconAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Request) {
var body removeServerIconAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-server-icon")
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", nil, "server icon removal validated", nil))
return
}
err := s.identity.RemoveIcon()
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
}
// --- .env editing --------------------------------------------------------
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
groups, err := s.serverCtl.ReadEnvGroups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, groups)
}
type updateServerEnvAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Values map[string]string `json:"values"`
}
func (s *server) handleUpdateServerEnvAPI(w http.ResponseWriter, r *http.Request) {
var body updateServerEnvAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server-env")
details := map[string]any{"keys_changed": len(body.Values)}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", nil, "would update .env -- takes effect on next Restart/Update", details))
return
}
err := s.serverCtl.WriteEnvValues(body.Values)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", err, ".env updated -- restart the server for changes to take effect", details))
}
// --- status / restart / update -------------------------------------------
func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.serverCtl.Status())
}
type restartServerAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleRestartServerAPI(w http.ResponseWriter, r *http.Request) {
var body restartServerAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "restart-server")
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", nil, "restart validated -- rebuilds and relaunches bin/owpengram-server", nil))
return
}
log, err := s.serverCtl.Restart(r.Context())
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", err, "server restarted", map[string]any{"log": log}))
}
type updateServerAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleUpdateServerAPI(w http.ResponseWriter, r *http.Request) {
var body updateServerAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server")
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", nil, "update validated -- git pull, rebuild both binaries, relaunch bin/owpengram-server (admin panel binary is rebuilt but not self-restarted)", nil))
return
}
log, err := s.serverCtl.Update(r.Context())
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", err, "server updated", map[string]any{"log": log}))
}

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-Bt9UBcEE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
</head>
<body>
<div id="root"></div>

View file

@ -23,6 +23,9 @@ import type {
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
EnvGroup,
ServerIdentity,
ServerStatus,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
@ -232,6 +235,11 @@ export const api = {
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
serverStatus: () => request<ServerStatus>("/api/server/status"),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

@ -9,6 +9,7 @@ import {
LogOut,
Megaphone,
MessageSquareText,
Settings,
ShieldAlert,
ShieldCheck,
Smile,
@ -18,7 +19,7 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
@ -57,6 +58,7 @@ export function Shell({
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
// Third-party verification is additionally hidden by default (not fully
// finished) regardless of what the session was granted -- see permissions.tsx.
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
@ -135,7 +137,13 @@ export function Shell({
</div>
)}
</div>
{canManageServer && (
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
)}
</nav>
<div className="sidebar-status">
<span className="sidebar-label">{"Version: O7"}</span>
</div>
</aside>
<div className="workspace">
<header className="topbar">

View file

@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage";
import { ServerSettingsPage } from "./ServerSettingsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { StoragePage } from "./StoragePage";
@ -27,6 +28,7 @@ import {
PermissionGate,
ThirdPartyVerificationHiddenGate,
permissionBotVerificationReview,
permissionServerManage,
permissionVerificationReview
} from "../permissions";
@ -124,6 +126,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
}
if (route.path === "/server-settings") {
return (
<PermissionGate permission={permissionServerManage}>
<ServerSettingsPage />
</PermissionGate>
);
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -0,0 +1,443 @@
import { ChevronDown, ImageOff, ImagePlus, Loader2, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, LoadingSurface, Metric, PageFrame, SectionHead } from "../components/ui";
import type { EnvGroup, ServerIdentity, ServerStatus } from "../types";
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
// menu -- admin-editable server name/description/icon (served to clients
// over /owpengram/server-info + /owpengram/server-icon), .env editing, and
// Restart/Update. See cmd/telesrv-admin/serversettings.go for the backend.
export function ServerSettingsPage() {
return (
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and process control -- mirrors the TUI panel"}>
<div className="stacked-sections">
<IdentitySection />
<ServerControlSection />
<EnvSection />
</div>
</PageFrame>
);
}
// --- Identity ---------------------------------------------------------
function IdentitySection() {
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconBust, setIconBust] = useState(0);
const [iconFailed, setIconFailed] = useState(false);
const [error, setError] = useState("");
async function load() {
setError("");
try {
const info = await api.serverIdentity();
setIdentity(info);
setName(info.name);
setDescription(info.description);
setIconFailed(false);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
return (
<section className="section-block">
<SectionHead title={"Server identity"} text={"Shown to clients auto-fetching this server's key -- takes effect immediately, no restart needed."} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading identity..."} />
) : (
<div className="card-body">
<div className="entity-head-main">
<div className="avatar-edit-slot">
{identity.icon_ext && !iconFailed ? (
<img
className="avatar-photo-img"
src={api.serverIconURL() + `&b=${iconBust}`}
alt=""
style={{ width: 56, height: 56 }}
onError={() => setIconFailed(true)}
/>
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
<ImageOff size={20} />
</div>
)}
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change server icon"}
title={"Change server icon"}
onClick={() => setIconModalOpen(true)}
>
<ImagePlus size={13} />
</button>
</div>
<div className="server-identity-fields">
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} onChange={(event) => setName(event.target.value)} /></label>
<label className="form-field"><span>{"Description"}</span><input value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</div>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
label={"Save identity"}
path="/api/actions/set-server-identity"
payload={() => ({ name, description })}
onDone={() => void load()}
/>
</div>
</div>
)}
{iconModalOpen && (
<ServerIconModal
hasIcon={!!identity?.icon_ext}
onClose={() => setIconModalOpen(false)}
onDone={() => { setIconBust((n) => n + 1); setIconFailed(false); void load(); }}
/>
)}
</section>
);
}
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; 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 submitUpload() {
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 }));
form.set("file", file, file.name);
const result = await api.uploadServerIcon(form);
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function submitRemove() {
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/remove-server-icon", { command_id: "", reason: reason.trim(), confirm: true });
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 server icon"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Server identity"}</div>
<h2>{"Change server icon"}</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=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif" 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 icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF 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 the server icon is changing"} 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>
{hasIcon && (
<button className="btn danger icon-text" type="button" onClick={() => void submitRemove()} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Trash2 size={15} />}
{"Remove icon"}
</button>
)}
<button className="btn primary icon-text" type="button" onClick={() => void submitUpload()} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{"Upload icon"}
</button>
</div>
</section>
</div>,
document.body
);
}
// --- Server control (status / restart / update) --------------------------
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// useAdminRestartWatcher backs the "the admin panel is bouncing itself"
// flow after Restart/Update: those actions ask owpengram-server to relaunch
// the admin process once *it* is back up (see internal/procctl's
// PendingAdminRestart), so from the browser's side this just means polling
// /api/session until a *different* boot_id answers -- proof a genuinely new
// process is up, not just that the old one is still slow -- then reloading
// the page. A timeout surfaces as a message with a manual reload button
// instead of spinning forever if something went wrong server-side.
function useAdminRestartWatcher() {
const [waiting, setWaiting] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const cancelled = useRef(false);
const watch = useCallback(async (timeoutMs = 150000) => {
cancelled.current = false;
setTimedOut(false);
setWaiting(true);
let baseline = "";
try {
baseline = (await api.session()).boot_id ?? "";
} catch {
// Falls through to polling anyway -- worst case it reloads on the
// first boot_id it manages to read, which is still correct.
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (cancelled.current) return;
await sleep(1500);
try {
const session = await api.session();
if (session.boot_id && session.boot_id !== baseline) {
window.location.reload();
return;
}
} catch {
// Expected mid-bounce: the old process is dying or the new one
// hasn't opened its listener yet. Keep polling.
}
}
setWaiting(false);
setTimedOut(true);
}, []);
const dismiss = useCallback(() => {
cancelled.current = true;
setWaiting(false);
setTimedOut(false);
}, []);
return { waiting, timedOut, watch, dismiss };
}
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
{timedOut ? (
<div className="command-body restart-overlay-body">
<Alert>{"The admin panel did not come back within the expected time. It may still be building/restarting -- reload manually in a bit, or check the server logs."}</Alert>
<div className="gift-table-actions restart-overlay-actions">
<button className="btn" type="button" onClick={onDismiss}>{"Dismiss"}</button>
<button className="btn primary" type="button" onClick={() => window.location.reload()}>{"Reload now"}</button>
</div>
</div>
) : (
<div className="command-body restart-overlay-body">
<Loader2 className="spin" size={28} />
<p>{label}</p>
</div>
)}
</section>
</div>,
document.body
);
}
function ServerControlSection() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [error, setError] = useState("");
const [overlayLabel, setOverlayLabel] = useState("");
const restartWatcher = useAdminRestartWatcher();
async function load() {
setError("");
try {
setStatus(await api.serverStatus());
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
return (
<section className="section-block">
<SectionHead
title={"Process control"}
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh status"}</button>}
/>
{error && <Alert>{error}</Alert>}
<div className="card-body">
{status && (
<div className="metric-row">
<Metric label={"owpengram-server"} value={status.ServerAlive ? `running (pid ${status.ServerPID})` : "stopped"} tone={status.ServerAlive ? "good" : "danger"} />
<Metric label={"admin panel"} value={status.AdminAlive ? `running (pid ${status.AdminPID})` : "stopped"} tone={status.AdminAlive ? "good" : "danger"} />
</div>
)}
<div className="gift-table-actions">
<ActionButton
tone="warn"
label={"Restart server"}
path="/api/actions/restart-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
<ActionButton
tone="danger"
label={"Update (git pull + rebuild + restart)"}
path="/api/actions/update-server"
payload={() => ({})}
onDone={() => {
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
void restartWatcher.watch();
}}
/>
</div>
</div>
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
</section>
);
}
// --- .env editor -----------------------------------------------------
function EnvSection() {
const [groups, setGroups] = useState<EnvGroup[]>([]);
const [values, setValues] = useState<Record<string, string>>({});
const [open, setOpen] = useState<Record<string, boolean>>({});
const [error, setError] = useState("");
async function load() {
setError("");
try {
const g = await api.serverEnv();
setGroups(g);
const next: Record<string, string> = {};
for (const group of g) {
for (const field of group.fields) {
next[field.key] = field.value;
}
}
setValues(next);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const fieldCount = useMemo(() => groups.reduce((sum, g) => sum + g.fields.length, 0), [groups]);
return (
<section className="section-block">
<SectionHead title={"Environment (.env)"} text={`${fieldCount} setting(s) across ${groups.length} group(s). Changes take effect on the next Restart/Update.`} />
{error && <Alert>{error}</Alert>}
<div className="env-groups">
{groups.map((group) => {
const isOpen = !!open[group.title];
return (
<div key={group.title} className={`env-group ${isOpen ? "open" : ""}`}>
<button
className="env-group-toggle"
type="button"
aria-expanded={isOpen}
onClick={() => setOpen((prev) => ({ ...prev, [group.title]: !prev[group.title] }))}
>
<span className="env-group-toggle-text">
<span className="env-group-toggle-title">{group.title}</span>
<span className="env-group-toggle-count">{`${group.fields.length} field${group.fields.length === 1 ? "" : "s"}`}</span>
</span>
<ChevronDown size={16} className="env-group-chevron" />
</button>
{isOpen && (
<div className="env-group-body">
{group.description && <p className="env-group-desc">{group.description}</p>}
{group.fields.map((field) => (
<label key={field.key} className="form-field env-field">
<span className="mono">{field.key}</span>
{field.description && <span className="env-field-desc">{field.description}</span>}
<input
type={field.sensitive ? "password" : "text"}
value={values[field.key] ?? ""}
placeholder={field.default_value}
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
/>
</label>
))}
</div>
)}
</div>
);
})}
</div>
<div className="gift-table-actions env-save-row">
<ActionButton
tone="warn"
label={"Save .env changes"}
path="/api/actions/update-server-env"
payload={() => ({ values })}
onDone={() => void load()}
/>
</div>
</section>
);
}

View file

@ -12,6 +12,9 @@ export const permissionVerificationRevoke = "verification.revoke";
// verifier roster, the icon catalogue and taking a granted mark away.
export const permissionBotVerificationReview = "botverification.review";
export const permissionBotVerificationManage = "botverification.manage";
// Server Settings: identity, .env, restart/update. One right, not
// review/manage -- see the constant's doc comment in security.go.
export const permissionServerManage = "server.manage";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This

View file

@ -31,5 +31,6 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/gif-catalog")) return "GIFs";
if (pathname.startsWith("/server-settings")) return "Server Settings";
return "Operations Console";
}

View file

@ -928,3 +928,134 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* --- Server Settings ---------------------------------------------------- */
/* Bare .card-body (outside .action-groups, which scopes its own flex rules)
just needs a sensible vertical rhythm below a SectionHead -- used as-is by
IdentitySection/ServerControlSection on the Server Settings page. */
.card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.server-identity-fields {
display: grid;
flex: 1 1 auto;
min-width: 0;
gap: 8px;
}
.server-icon-fallback {
color: var(--muted);
background: var(--panel-subtle);
border: 1px dashed var(--line-strong);
}
.env-groups {
display: grid;
gap: 8px;
}
.env-group {
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.env-group-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 14px;
background: var(--panel-subtle);
border: none;
cursor: pointer;
text-align: left;
transition: background-color 140ms ease;
}
.env-group-toggle:hover {
background: var(--brand-tint);
}
.env-group-toggle-text {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.env-group-toggle-title {
color: var(--heading);
font-size: 13px;
font-weight: 800;
}
.env-group-toggle-count {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.env-group-chevron {
flex-shrink: 0;
color: var(--muted);
transition: transform 140ms ease;
}
.env-group.open .env-group-chevron {
transform: rotate(180deg);
}
.env-group-body {
display: grid;
gap: 12px;
padding: 14px;
border-top: 1px solid var(--line);
}
.env-group-desc {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.env-field .env-field-desc {
color: var(--muted);
font-size: 11px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
.env-save-row {
margin-top: 12px;
}
.restart-overlay {
width: min(440px, 100%);
}
.restart-overlay-body {
display: grid;
justify-items: center;
gap: 12px;
padding: 28px 20px;
text-align: center;
}
.restart-overlay-body p {
margin: 0;
color: var(--text-soft);
font-weight: 700;
}
.restart-overlay-actions {
justify-content: center;
}

View file

@ -580,6 +580,10 @@ export type AdminSession = {
// 404, so this is a UI convenience on top of a real enforcement, not the
// enforcement itself.
hide_third_party_verification?: boolean;
// Random per admin-process-start value -- see the Go handler's doc
// comment. Used by Server Settings' Restart/Update flow to detect a
// genuinely new admin process after asking it to bounce.
boot_id?: string;
};
export type AdminLoginResult = AdminSession & {
@ -829,3 +833,31 @@ export type GroupMessageListResponse = {
limit: number;
rows: GroupMessageRow[];
};
export type ServerIdentity = {
name: string;
description: string;
icon_ext?: string;
};
export type EnvField = {
key: string;
default_value: string;
description: string;
enabled_by_default: boolean;
sensitive: boolean;
value: string;
};
export type EnvGroup = {
title: string;
description: string;
fields: EnvField[];
};
export type ServerStatus = {
ServerPID: number;
ServerAlive: boolean;
AdminPID: number;
AdminAlive: boolean;
};