merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -64,7 +64,7 @@ func run() error {
}
defer pool.Close()
hs := hoststats.NewPoller(cfg.BlobDir)
hs := hoststats.NewPoller(cfg.DiskStatsPath)
go hs.Run(ctx, hostStatsPollInterval)
srv, err := newServer(cfg, newReadStore(pool), hs)
@ -97,10 +97,10 @@ type uiConfig struct {
Password string
Token string
SessionKey []byte
// BlobDir is the local blob-storage root, reused only to pick which
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
BlobDir string
// DiskStatsPath points the dashboard host-disk sampler at the local path
// that matters for the selected blob backend: permanent localfs storage or
// the S3 upload spool.
DiskStatsPath string
// Permissions is the right set a panel session is issued with, from
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
// entry, so introducing the permission model never locks an operator out of a
@ -163,14 +163,21 @@ func loadConfig() (uiConfig, error) {
Password: appCfg.AdminUIPassword,
Token: appCfg.AdminUIToken,
SessionKey: sum[:],
DiskStatsPath: dashboardDiskPath(appCfg),
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
BlobDir: appCfg.BlobDir,
IdentityDir: appCfg.IdentityDir,
RepoRoot: repoRoot,
}, nil
}
func dashboardDiskPath(cfg config.Config) string {
if strings.EqualFold(strings.TrimSpace(cfg.BlobBackendKind), "s3") && strings.TrimSpace(cfg.BlobStagingDir) != "" {
return cfg.BlobStagingDir
}
return cfg.BlobDir
}
func adminAPIURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {

View file

@ -36,6 +36,8 @@ import (
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
const (
permissionAll = "*"
permissionPremiumManage = "premium.manage"
permissionBotTokenRead = "bots.token.read"
permissionVerificationReview = "verification.review"
permissionVerificationRevoke = "verification.revoke"
// Third-party bot verification. Deliberately not implied by the official

View file

@ -110,7 +110,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(s.requirePermission(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI))))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))

View file

@ -144,6 +144,53 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
}
}
func TestAuthorizationRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(AuthorizationRow{AuthKeyID: maxInt64, Hash: maxInt64})
if err != nil {
t.Fatalf("marshal authorization row: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal authorization row: %v", err)
}
for _, field := range []string{"AuthKeyID", "Hash"} {
if got[field] != "9223372036854775807" {
t.Fatalf("authorization %s = %#v, want exact decimal string", field, got[field])
}
}
}
func TestRevokeSessionsBFFForwardsExactAuthorizationHash(t *testing.T) {
const authorizationHash = int64(2361577175213625973)
var got admin.RevokeSessionsRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/accounts/revoke-sessions" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-sessions", strings.NewReader(`{
"reason":"precision regression","confirm":false,"user_id":1001,
"hash":"2361577175213625973"
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleRevokeSessionsAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.Hash != authorizationHash || got.Actor != "operator" || !got.DryRun {
t.Fatalf("forwarded revoke request = %+v", got)
}
}
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.MintCollectibleUsernameRequest

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-D8u51wND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hA2EpjuH.css">
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
</head>
<body>
<div id="root"></div>

View file

@ -758,9 +758,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{

View file

@ -193,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
}
return ids;
}
// toUnixSeconds reads a datetime-local input. Such an input carries no zone, so
// the value parses as the operator's local time — which is the time they picked.
// 0 means "empty or unparseable", which every caller treats as "not scheduled".
export function toUnixSeconds(value: string): number {
if (!value.trim()) return 0;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
}
// localInputValue formats a datetime-local default some seconds out, so a
// scheduling form never opens on a value the server would reject as past.
export function localInputValue(offsetSeconds: number): string {
const at = new Date(Date.now() + offsetSeconds * 1000);
const pad = (n: number) => String(n).padStart(2, "0");
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
}

View file

@ -166,7 +166,7 @@ export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): str
export function priceLabel(row: CollectibleUsernameRow): string {
const base = formatCurrency(row.Amount, row.Currency);
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
}
return base;
}

View file

@ -5,6 +5,8 @@ import { Alert, PageFrame } from "./components/ui";
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session.
export const permissionAll = "*";
export const permissionPremiumManage = "premium.manage";
export const permissionBotTokenRead = "bots.token.read";
export const permissionVerificationReview = "verification.review";
export const permissionVerificationRevoke = "verification.revoke";
// Third-party verification is a separate mechanism and therefore a separate pair of

View file

@ -425,3 +425,34 @@
transform: rotate(360deg);
}
}
.secret-reveal {
display: grid;
gap: 6px;
padding: 10px;
background: var(--warn-tint);
border: 1px solid var(--warn-border);
border-radius: var(--radius);
}
.secret-reveal-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--warn);
font-size: 12px;
font-weight: 800;
}
.secret-reveal-row { display: flex; align-items: center; gap: 10px; }
.secret-reveal-value {
overflow: hidden;
flex: 1 1 auto;
padding: 6px 10px;
color: var(--text-soft);
letter-spacing: .12em;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
text-overflow: ellipsis;
white-space: nowrap;
}