Compare commits

...

101 commits

Author SHA1 Message Date
669e515061 botfather: fix two remaining call sites after branding rebase
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Admin web build (push) Waiting to run
CI / Grammy store bot (push) Waiting to run
CI / Docker main topology smoke (push) Waiting to run
botFatherHelpText became a func() string during the rebase onto upstream
(matching upstream's own runtime-configurable-branding-safe pattern); two
call sites still referenced it as a bare value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:12:13 +01:00
33e22308c6 deps: bump github.com/iamxvbaba/td to v1.3.2
Matches upstream owpengram/owpengram-server's version. v1.3.2 collapses the
old per-kind KeyboardButtonClass sum type (KeyboardButton, KeyboardButtonURL,
KeyboardButtonCallback, KeyboardButtonRequestPeer, ...) into two unified
structs mirroring Telegram's actual current MTProto layer: KeyboardButton
(reply keyboards) and KeyboardInlineButton (inline keyboards), each carrying
a Text/Style pair plus a Type field (ButtonTypeClass / InlineButtonTypeClass)
that now holds what used to be the concrete Go type.

Migrated the two call sites (internal/rpc/convert_markup.go,
internal/rpc/bots_longtail.go) and their tests to the new shape. Behavior is
unchanged -- every button kind (callback, url, url_auth, web_view,
switch_inline, copy, request_phone, request_geo_location, request_poll,
request_peer, simple_web_view) still round-trips the same domain fields,
just read from/written to Type instead of the button's own concrete type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:09:36 +01:00
6435406690 account: stop rotating the SRP challenge on every getPassword read
GetPassword minted a brand-new random SRP server secret and B on every call
while only ever assigning SRPID once (when zero). Two account.getPassword
calls in a row -- e.g. a settings screen refreshing state, then the transfer-
ownership dialog's own cloudPassword().reload() moments later -- silently
invalidated each other's B with no signal the client could detect (SRPID
unchanged), so a password check built from the first response's B failed
with PASSWORD_HASH_INVALID even though the typed password was correct.

The challenge now stays stable across reads and only rotates when it's
missing entirely; UpdatePasswordSettings/RecoverPassword already mint their
own fresh challenge whenever the password actually changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:06:46 +01:00
1f6f25074a build.sh: restart via systemd instead of managing containers directly
owpengram-server and owpengram-admin are now systemd-managed units; build.sh
no longer needs to podman create/start them itself, just build the image and
restart the two services so they pick up the new image. The pod itself still
isn't systemd's job, so build.sh keeps ensuring it exists first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:06:46 +01:00
4ca35d2000 users: never cache a deleted user's base row
redisstore.userBaseValue has no Deleted/DeletedAt/Status field, so caching a
deleted user silently reset Deleted back to false (and Status to the zero
UserStatusUnknown) on every round trip. That never self-healed: each later
cache miss reloaded the correctly tombstoned DB row and immediately
re-corrupted it on write, so once anyone looked a deleted account up, it kept
showing a blank name with "last seen recently" instead of "Deleted Account".

Keep deleted users off the base cache entirely so lookups always hit the
authoritative store, and stop presence overlay from touching a Deleted user's
Status at all as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:06:46 +01:00
f823b2cb74 rpc: return PASSWORD_MISSING for channel transfer without 2FA
messages.editChatCreator unconditionally returned PASSWORD_HASH_INVALID for
an account with no cloud password at all. Real Telegram Desktop's transfer-
ownership flow only recognizes the distinct PASSWORD_MISSING error to show
its "enable 2FA first" box; anything else falls through into the real
password-entry flow, which then has nothing to check against and crashes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:05:28 +01:00
61f6685603 tools/createuser: report which field collided on insert failure
ON CONFLICT (id) DO NOTHING only catches the id itself, so every other
failure (duplicate username/phone/signup_email) was reported as a generic
"already exists (or insert failed)" - not useful for telling apart the four
distinct causes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:05:28 +01:00
3ae27c435d container: build and ship cmd/createuser in the server image
Mirrors the telesrv-admin pattern - a static build stage compile, copied
into the final alpine image as /app/createuser - so the tool is
available wherever the server image runs, not just from a local go run.
2026-09-14 12:05:28 +01:00
caa4d955e5 tools: add createuser command for reserving a custom user id
auth.signUp never lets a caller pick a user id (users_id_seq always
assigns it), but users.id is GENERATED BY DEFAULT rather than ALWAYS, so
an explicit id in the INSERT is honored - the same mechanism
ensureOfficialSystemUserWithDB already relies on to seed the built-in
system accounts at fixed ids.

createuser -id N [-phone ...|-email ...] inserts a user row at that id
for local/dev use, refusing (unless -force) a reserved system-account id
or one at/above UserIDSequenceBase where a future organic signup could
collide with it. -email reproduces the real email-signup path exactly:
a synthetic 888-prefixed display phone (domain.NewEmailSignupDisplayPhone,
re-rolled on collision) plus the real address in signup_email, rather
than storing the address in users.phone directly.
2026-09-14 12:05:28 +01:00
eb23afdd39 docs: compare OwpenGram Server's implemented features to Telegram's official server
Feature-by-feature comparison across transport/auth, messaging, groups,
media, bots, calls, stories, secret chats, sync, themes, moderation, and
the payments/Stars economy - marking each Full / Partial / Stub / None,
with the deliberate omissions and OwpenGram-only additions called out
separately.
2026-09-14 12:05:28 +01:00
3289882dbd smtp: add Date and Message-ID headers to outgoing mail
RFC 5322 requires both headers. Date uses RFC1123Z formatting; Message-ID
is a random 16-byte token scoped to the sending domain parsed from From.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:05:28 +01:00
f1c24e483c usernames: report reserved names as taken in the check paths too
account.checkUsername / channels.checkUsername / bots.checkUsername said a
reserved name was available and only updateUsername rejected it. Add the
blocklist check to peerUsernameAvailable (covers account + channel, both
backends) and to bots.Service.CheckUsername, so the client shows "username is
taken" immediately.
2026-09-14 12:05:28 +01:00
60537342d3 admin ui: reserved usernames use the standard dry-run/confirm flow
Both reserve and unreserve go through ActionButton now (reason -> dry-run ->
confirm, journalled) like every other admin action. The reserve modal keeps
just the username field and hands off; it autofocuses and echoes @<name> live
so the field being filled is unambiguous.
2026-09-14 12:05:28 +01:00
d6d3be0070 admin ui: self-contained reserve-username modal, plain @ text
The reserve modal delegated to a nested ActionButton, whose own flow modal
opened over it - the username field ended up behind it and the request preview
came through empty on confirm. Replace it with a modal that owns its username
and reason fields and posts the reserve/unreserve command directly. Render the
@ prefix as text, not an icon.
2026-09-14 12:05:18 +01:00
9ed8590264 admin ui: match the reserved-usernames page layout to the NFT page
Move "Reserve username" into a modal opened from the page actions, and keep a
single search toolbar in the query panel, so the page matches Collectible
Usernames instead of stacking two toolbars with an unconstrained input.
2026-09-14 12:05:06 +01:00
2bdb1ecf37 usernames: operator reserved-username blocklist
A plain blocklist for names like @support - separate from the collectible
system, so a reservation has no owner, no price and no "bought on Fragment"
badge.

- reserved_usernames table + migration.
- Enforced in replacePeerUsernameTx (the single editable-username write point:
  account.updateUsername, channels.updateUsername, @BotFather /setusername) and
  in the collectible mint path; a reserved name returns USERNAME_OCCUPIED.
- admin.Service: ReserveUsername / UnreserveUsername (journalled commands) and
  the ReservedUsernames listing.
- adminapi: /v1/reserved-usernames{,/reserve,/unreserve}.
- telesrv-admin panel + a "Reserved Usernames" page in the web UI (dist rebuilt).
- Postgres and in-memory store implementations; the memory registry gains an
  optional reserved-name check so tests exercise the same rule.
2026-09-14 12:04:43 +01:00
22846e340f botapi: getChatMemberCount, getChatMember, and a fuller getChat
- getChatMemberCount: channel/supergroup participant count (numeric chat_id).
- getChatMember: resolves a member via GetParticipant, projected to a Bot API
  ChatMember (creator/administrator/restricted/member/left/kicked with the
  matching rights); a user simply not in an accessible chat returns "left".
- getChat now uses the full channel view and adds permissions (from the default
  restrictions), slow_mode_delay, linked_chat_id and pinned_message.

Channel-only methods reject user chat_ids; private chats the bot cannot access
return CHAT_NOT_FOUND.
2026-09-14 12:00:03 +01:00
f5a0e770c7 botapi: implement getChat
Adds the getChat method to the HTTP Bot API gateway. Numeric chat_id only (no
@username). Resolution goes through the shared peer resolvers:

- user: ByID; unknown -> CHAT_NOT_FOUND
- channel/supergroup: ResolveChannel, so a public one resolves even when the bot
  is not a member (projected as a preview); a private one the bot cannot access
  -> CHAT_NOT_FOUND, a banned bot likewise

The Chat projection returns id (bot-api encoded), type ("channel" for a
broadcast, "supergroup" for a megagroup, "private" for a user), title, username,
first/last name, description, is_forum, and the scam/fake/verified flags.
2026-09-14 12:00:03 +01:00
7aab677ce7 build: map port 2500 on the pod 2026-09-14 12:00:03 +01:00
213b7c5234 build: create the pod with its port mappings if missing
Instead of erroring when the owpengram pod is absent, build.sh now creates it
with the MTProto (2398), admin (2600), extra TCP (2400) and RTC/UDP port
mappings.
2026-09-14 12:00:03 +01:00
494e76dc70 build: recreate and start the pod containers after building
build.sh now, after building the image, recreates owpengram-server and
owpengram-admin in the pod (podman create --replace) and starts them. Guards
that the pod and .env exist; NO_DEPLOY=1 keeps the old build-only behaviour,
POD overrides the pod name.
2026-09-14 12:00:03 +01:00
94b3113a37 botfather: /start <bot> opens that bot's menu
The "Manage Bot" button on a bot's profile deep-links to @BotFather with
start=<bot username>. parseBotCommand dropped the argument, so /start <bot>
just replied with the generic greeting instead of the per-bot menu.

Route "/start <arg>" to the bot's "What do you want to do?" screen (same as
/mybots then tapping the bot) when <arg> names one of the sender's own bots by
username or id; empty or unknown args keep the greeting.
2026-09-14 12:00:03 +01:00
5a89a83caf admin: list accounts that have no active sessions
The Accounts tab (readStore.ListAccounts) inner-joined the authorizations
aggregate, so any account with zero authorization rows was silently hidden -
accounts that never finished login, had all sessions revoked, or were frozen
then unfrozen. CountAccounts and SearchAccounts already LEFT JOIN, so the count
and search disagreed with the list.

Switch ListAccounts to LEFT JOIN auth and COALESCE the null last_active_at /
device_count (sessionless accounts sort last), matching SearchAccounts.
2026-09-14 12:00:03 +01:00
2014c98386 forum: let non-members preview topic replies in a public channel
ListChannelReplies used getChannelForMemberOrLinkedGuest, so messages.getReplies
was member-only. ListChannelHistory (flat history) uses getChannelForViewer and
already allows a public channel's non-members to preview it. The mismatch meant
that on a public forum you could preview the flat history but not the topics -
and after leaving, tdesktop's topic view got CHANNEL_PRIVATE and sat on
"Loading..." forever instead of rendering a preview.

Switch the primary channel lookup in ListChannelReplies (both stores) to the
viewer-scope path. Private channels still return CHANNEL_PRIVATE to non-members;
the broadcast comment-thread lookup is unchanged.
2026-09-14 11:58:47 +01:00
4f7d619955 build: stamp git metadata into the container image
.containerignore excludes .git, so go build's automatic VCS stamping produced
nothing and telesrv logged git_commit/git_branch/git_tree_state/build_time as
"unknown" on startup.

- Containerfile: accept GIT_COMMIT/GIT_BRANCH/GIT_TREE_STATE/BUILD_TIME build
  args and pass them to the gramsrv build via -ldflags -X.
- build.sh: wrapper that fills those args from the current checkout and runs
  podman build.
2026-09-14 11:58:47 +01:00
0991207802 forum: project the forum's own channel with member state in getForumTopics
messages.getForumTopics returned every chat via tgChannels -> tgChannelChatMin,
so the forum's own channel came back as a min object with left unset. A client
with no other object for that peer (a fresh account browsing a public forum by
username) then rendered the forum as already joined: topic list visible, no
Join button, but no messages.

Render the primary channel with tgChannelChatForView so a non-member preview
carries left=true; keep the other referenced channels as min.
2026-09-14 11:58:47 +01:00
c8380b4257 welcome message: point updates-channel mention at @ziodotsh
Rename the official updates channel mention from @zio to @ziodotsh in the
welcome message template and update the affected send-message test.
2026-09-14 11:58:47 +01:00
b047a90271 channels: drop stale membership caches on join/leave
After channels.leaveChannel, a client that polls channels.getFullChannel kept
receiving a projection that still showed it as an active member (left=false)
until the per-(viewer,channel) RPC projection cache and the store-level member
cache lapsed on their own or the async read-model NOTIFY landed. The client
therefore kept an open compose box while every send was already rejected with
CHANNEL_PRIVATE - most visible on public forum supergroups, where getFullChannel
keeps succeeding via the preview path instead of tearing the chat down.

Every other membership-mutating path already busts these caches synchronously;
join/leave/invite/request-approval did not. Add:

- store: invalidateChannelMembershipCaches (row + member + dialog caches),
  called post-commit from JoinChannel, LeaveChannel, ImportInvite,
  InviteToChannel.
- rpc: invalidateChannelMembershipProjection (channelFullProjectionCache pair),
  called from the join/leave/invite/hide-requests handlers for every user whose
  membership changed.
2026-09-14 11:58:11 +01:00
012f8a8d0e channels: force pre-history visible when a group gets a public username
New supergroups are created with "chat history for new members" hidden (the
client sets this right after creation, matching official Telegram). The
official server then forces it back to visible when the group is made public;
owpengram's UpdateUsername left the flag alone, leaving public groups in a
state where non-members (and post-join members) see no history at all.

UpdateUsername now clears pre_history_hidden whenever a non-empty username is
assigned, in the same transaction, with a matching admin-log event. Removing
the username leaves the flag untouched, so the creator can hide history again
once the group is private.
2026-09-14 11:57:12 +01:00
eb7cdd1a77 channels: give getParticipants a stable Hash when read-model versions are missing
cachedParticipants returned a participant page with Hash=0 whenever the
channel_base / channel_participants rows in read_model_versions were never
seeded for a channel (e.g. groups created via messages.createChat). With
Hash=0 the RPC layer can never answer channels.channelParticipantsNotModified,
so a client that polls the member list re-fetches it in a tight loop forever.

Fall back to a deterministic content hash derived from the page itself
(channel id, page key, count, and each member's id/role/status/rank) so an
unchanged member list yields an identical non-zero Hash and the client
converges. The read-model-backed path is unchanged.
2026-09-14 11:55:23 +01:00
c7a77c23c8 forum: fix reply_to_top_id for replies inside a forum
resolveChannelReply applied discussion-thread logic (reply_to_top_id =
the replied-to message's own id) to forum replies. Replying to a General
message produced reply_to_top_id = <that id>, a topic no client can
resolve: the reply vanished from every topic view and reply-jump on
strict clients said "message doesn't exist".

Forum replies now inherit the target's topic via domain.ForumReplyTopicID
(target's topic, or its own id if it's a topic-create, else General), and
General (topic 1) is accepted as a valid virtual topic everywhere, so
sends carrying top_msg_id: 1 are no longer rejected. Non-forum discussion
threads are unchanged.
2026-09-14 11:55:23 +01:00
f0bf315bf3 forum: let non-members browse a public forum's topic list
ListForumTopics / GetForumTopicsByID / GeneralForumTopic gated on
membership while channel history uses the public-preview path, so a
public forum's topics (General included) were invisible until you joined.
Switch them to getChannelForViewer / channelForViewerLocked; private
forums and write paths keep the membership gate.
2026-09-14 11:55:23 +01:00
57b7829a9e media: never project zero image/video dimensions (crashes Telegram Desktop on reactions) 2026-09-14 11:55:23 +01:00
d4fe056854 welcome message: link @zio with a mention entity 2026-09-14 11:55:23 +01:00
4d781a52bb payments: stub getStarGifts / getSavedStarGifts to stop tdesktop 500 retry storm 2026-09-14 11:53:43 +01:00
db3fbe6015 Containerfile: copy sticker-seed data into the image 2026-09-14 11:53:43 +01:00
5afafc60c4 /mybots: Edit Bot summary screen, return-to-menu, clickable @mentions
- Edit Bot now shows the current value of every field (Name/About/
  Description/Botpic/Commands) like BotFather, with real botpic status
  via a new PeerHasAvatar port method.
- After editing a field the dialog lands back on a fresh Edit Bot menu
  (working "Back to bot" / "Bots list" buttons) instead of ending, so a
  follow-up button press no longer reports the button as expired.
- Service-bot messages now render @username as a tappable mention entity.
2026-09-14 11:53:43 +01:00
f474a360d7 Interactive /mybots menu for @BotFather
Button-driven bot management: paginated picker, per-bot API token /
revoke, Edit Bot (name/description/about/commands/botpic), Bot Settings
toggles (inline/groups/privacy), and delete. Navigation edits the menu
message in place via a new editServiceBotMessage helper.

Edit Botpic accepts a photo the user sends to @BotFather and sets it as
the bot's profile photo (new files.SetAvatarFromExistingPhoto, wired
through bots.SetBotUserpic / WithBotAvatarStore); photos.uploadProfilePhoto
does not accept a bot target so this is the only route.
2026-09-14 11:52:52 +01:00
196f90f9b1 Add Containerfile and .containerignore 2026-09-14 11:52:52 +01:00
09e2d24a4a Don't 500 on channel sends with an unresolvable @mention
A channel post containing an @token that is not a syntactically valid
username (too short, leading digit, etc.) made messages.sendMessage return
500 INTERNAL_SERVER_ERROR: mentionedUserIDsFromMessage turned every
ResolveUsername error into internalErr().

Skip tokens that fail with ErrUsernameInvalid / ErrUsernameNotOccupied
instead, matching real Telegram (the message sends, the client renders the
mention and only fails to open it on tap). Only unexpected storage errors
still abort. Same fix applied to the bot send path.
2026-09-14 11:52:52 +01:00
onysd128
4e148052b6
Create FUNDING.yml 2026-09-13 08:44:23 +03:00
onysd
d8d6788a1c fixed script install 2026-09-13 05:11:34 +03:00
onysd
cd59cd8d28 fix for minio docker image 2026-09-13 02:56:39 +03:00
onysd
e4debd6b7d linux server installer quickfix 2026-09-13 02:02:19 +03:00
onysd
22b308483d updated readme 2026-09-12 19:45:47 +03:00
onysd
7c0639cf6a fix for comments in groups 2026-09-12 19:14:53 +03:00
onysd
93e5d4229d fix 2026-09-11 18:50:20 +03:00
onysd
a98c0beffc fix 2026-09-11 18:44:49 +03:00
onysd
559769af88 fix 2026-09-11 18:25:08 +03:00
onysd
6d026e16f2 fix 2026-09-11 17:23:42 +03:00
onysd
27c8e9a434 fix 2026-09-11 17:21:44 +03:00
onysd
03de33c418 fix 2026-09-11 16:59:32 +03:00
onysd
4224b9d15c updated server install scripts 2026-09-11 16:19:13 +03:00
onysd
7ad68c3983 added full access row to operator modal, plus a test for the last-manager guard
The "*" wildcard was never in assignablePermissions, so an operator holding it
(the one the first-run wizard creates) showed every box unticked while having
every right, and there was no way to take it away. Its own row fixes both; the
grid is disabled while it is on, since normalisePermissions collapses "*" plus
anything back to "*".

That made guardManagerRemoval reachable from the UI for the first time, so it
now has an integration test covering the wildcard match and the enabled filter
in its SQL.
2026-09-11 15:48:07 +03:00
onysd
c04a8ddc6a added bot api question on wev-setup 2026-09-11 05:15:00 +03:00
onysd
2f1818d656 merged with fixes 2026-09-09 02:49:30 +03:00
onysd
a9e758b712 update gitignire 2026-09-08 21:25:11 +03:00
onysd
274bdc8bac fixing bugs 2026-09-08 18:59:58 +03:00
onysd
979d27ec7a improvements for first-time setup 2026-09-08 17:56:26 +03:00
onysd
e59d85cf57 made scripts just an easy startup and first setup over web admin page 2026-09-08 15:18:19 +03:00
onysd
7b788408b9 adjusted login form sizes and fixed routing issues after login 2026-09-08 13:52:39 +03:00
onysd
d5eb77e2d6 removed useless text on login form 2026-09-08 13:44:04 +03:00
onysd
2584380a80 glorifying login page 2026-09-08 13:40:48 +03:00
onysd
e240bfbbfa added dry run for update button 2026-09-08 13:25:00 +03:00
onysd
5c16371ae0 added cache for dashboard and storage pages 2026-09-08 02:29:28 +03:00
onysd
eb69c8500c now left menu in admin panel can be minimized 2026-09-08 02:24:58 +03:00
onysd
db40f100cd added 403 screen when operator have no permission for section 2026-09-08 02:19:09 +03:00
onysd
f7b3af48de added background to panel itself 2026-09-08 02:02:18 +03:00
onysd
87173ae43f glorifying admin panel 2026-09-08 01:53:49 +03:00
onysd
ae2cc3ba90 messages screen improvement 2026-09-08 01:19:33 +03:00
onysd
280321b902 server admin panel is now supports multiple operators profiles 2026-09-08 01:01:35 +03:00
onysd
e48160ac3a fix for query 2026-09-07 18:57:42 +03:00
onysd
5f94d3e028 fixes for parallel info loading for admin panel 2026-09-07 18:52:51 +03:00
onysd
62849c532e added skeletons to admin page when data on dashboard and storage page are loading 2026-09-07 18:40:56 +03:00
onysd
87cebec9bd fix for muting an account 2026-09-07 09:31:06 +03:00
onysd
aeaf3f4596 updates for server files size limits 2026-09-06 07:14:50 +03:00
onysd
b65ad60fe8 fix for deleted file metadata 2026-09-03 11:00:50 +03:00
onysd
3fef764ece fix 2026-09-03 10:44:17 +03:00
onysd
508d120bd3 fix for max file size 2026-09-03 10:31:45 +03:00
onysd
a20f5b8b33 fix for ui 2026-09-03 10:19:17 +03:00
onysd
5a7618aaba added danger zones to storage managament menu 2026-09-03 10:04:46 +03:00
onysd
a00f6ad814 fix for avatar updates 2026-09-03 09:50:15 +03:00
onysd
9cf53449fd fixes and ui improvements 2026-09-03 09:37:55 +03:00
onysd
ec888d3a26 fix for files purge 2026-09-03 08:42:01 +03:00
onysd
863ae2e990 files for previous commit 2026-09-03 08:33:27 +03:00
onysd
8ef2b58bf9 fixes for storage managament 2026-09-03 08:33:06 +03:00
onysd
e6bfe2d444 fix for retention logic 2026-09-03 05:00:42 +03:00
onysd
70c0ba44f0 adding more functions to media managament system 2026-09-03 00:54:09 +03:00
onysd
95e62c2d77 fix 2026-09-01 20:06:58 +03:00
onysd
d2f4e11390 fixed info about api layer in admin page 2026-09-01 19:12:32 +03:00
onysd
62d8b53030 fix 2026-09-01 17:40:03 +03:00
onysd
7cd1f64d0d added messages templates 2026-09-01 14:40:06 +03:00
onysd
e8dc967e6a fixes 2026-09-01 12:50:18 +03:00
onysd
21a0856587 merged from gramsrv upstream 2026-09-01 12:06:31 +03:00
onysd
79c64ee916 fix 2026-08-27 13:23:17 +03:00
onysd
ef325f31da fix 2026-08-26 03:06:57 +03:00
onysd
f3c4e3c60b fix 2026-08-26 02:53:47 +03:00
onysd
1c9a192a96 improved settings window 2026-08-26 02:44:59 +03:00
onysd
80bc8d352c still tuning server settings section 2026-08-26 01:54:19 +03:00
onysd
66f9c0bc1e fixes and improvements for new server settings menu 2026-08-26 00:37:25 +03:00
onysd
902f3606c2 added endpoint for clients auto fetch for server dc and key 2026-08-25 21:02:50 +03:00
919 changed files with 87752 additions and 6886 deletions

5
.containerignore Normal file
View file

@ -0,0 +1,5 @@
.git
bin
*.pem
.env
.env.*

42
.dockerignore Normal file
View file

@ -0,0 +1,42 @@
.git
.github
.codex-tmp
.gocache
.tdesktop-e2e
.vscode
.idea
# Local builds, caches, and runtime state.
bin
dist
logs
tmp
coverage.*
*.exe
*.test
*.out
**/node_modules
**/__pycache__
**/*.pyc
# Deployment credentials stay out of the build context. The explicitly
# published test RSA fixture below is the only private-key exception.
.env
.env.*
**/.env
**/.env.*
codex.local
secrets
**/secrets
*.pem
*.key
# Runtime data is excluded except for the tracked language-pack seed.
data/*
!data/langpack/
!data/langpack/**
# Deployment-local state and overrides.
deploy/docker/.env
deploy/docker/backups
deploy/docker/overrides

View file

@ -40,6 +40,24 @@ TELESRV_PHONE_CODE_LENGTH=5
TELESRV_AUTH_CODE_TTL=5m TELESRV_AUTH_CODE_TTL=5m
# How many wrong guesses are allowed before a code is rejected outright. # How many wrong guesses are allowed before a code is rejected outright.
TELESRV_AUTH_CODE_MAX_ATTEMPTS=5 TELESRV_AUTH_CODE_MAX_ATTEMPTS=5
# The message sent from the official system account (777000) into a user's
# own chat on every completed sign-in -- a lightweight security notice, not
# the login code itself. {{server_name}} is replaced with the server's
# configured identity name (or product name if unset). Leave empty to use
# the built-in English copy. The admin panel's Server Settings page can
# override these live, without a restart; these env vars are only the
# fallback for when it hasn't been touched.
TELESRV_WELCOME_MESSAGE_PHONE_TEMPLATE=
TELESRV_WELCOME_MESSAGE_EMAIL_TEMPLATE=
# The message sent from the official system account (777000) that carries the
# actual login code -- one template for every delivery channel (SMS or
# email). Must contain the {{code}} placeholder exactly once (that's where
# the real code is inserted and bolded); {{server_name}} is optional and may
# appear any number of times. Leave empty to use the built-in English copy.
# The admin panel's Server Settings page can override this live, without a
# restart (rejecting a save that doesn't contain {{code}} exactly once); this
# env var is only the fallback for when it hasn't been touched.
TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE=
# Where webhook-delivered codes are POSTed, and the shared secret used to # Where webhook-delivered codes are POSTed, and the shared secret used to
# sign that request (see docs/otp-delivery.md for the exact payload). # sign that request (see docs/otp-delivery.md for the exact payload).
TELESRV_OTP_WEBHOOK_URL= TELESRV_OTP_WEBHOOK_URL=
@ -82,17 +100,24 @@ TELESRV_SMTP_TIMEOUT=10s
## Public Links & Branding -- What clients show/open for links, and your product's name. ## Public Links & Branding -- What clients show/open for links, and your product's name.
# Public web address for links this server generates (invite links, sticker # Public web address for links this server generates (invite links, sticker
# packs, etc). Use your real domain once you have one, e.g. https://example.com. # packs, etc). :2401 matches TELESRV_PUBLIC_LINK_WEB_ADDR below (the Public
TELESRV_PUBLIC_BASE_URL=http://127.0.0.1 # Web Listener that actually serves those preview pages) so a link opened
# right after setup shows a real preview card instead of a dead connection.
# Use your real domain once you have one, e.g. https://example.com -- and
# put nginx (or similar) in front of the listener rather than pointing this
# at the raw port.
TELESRV_PUBLIC_BASE_URL=http://127.0.0.1:2401
# Custom URL scheme (like "owpg://") that public pages use to open your # Custom URL scheme (like "owpg://") that public pages use to open your
# patched client. Must match what your client builds were compiled with. # patched client. Must match what your client builds were compiled with.
TELESRV_PUBLIC_APP_SCHEME=owpg TELESRV_PUBLIC_APP_SCHEME=owpg
# Optional: use "scheme://yourdomain.com/..." links instead of plain # Optional: use "scheme://yourdomain.com/..." links instead of plain
# "scheme://...". Leave empty unless you specifically need this. # "scheme://...". Leave empty unless you specifically need this.
TELESRV_PUBLIC_APP_LINK_BASE= TELESRV_PUBLIC_APP_LINK_BASE=
# Address of your web client (if you have one) and the product name shown # Address of your web client, if you have one -- shows an "Open in Web"
# on public landing pages. # button on public profile/invite pages. Leave empty (the default) to hide
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net # that button; there's no web client running unless you've deployed one.
TELESRV_PUBLIC_WEB_BASE_URL=
# Product name shown on public landing pages.
TELESRV_PUBLIC_APP_NAME=OwpenGram TELESRV_PUBLIC_APP_NAME=OwpenGram
# Where the "Download" button on public pages links to. # Where the "Download" button on public pages links to.
TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
@ -100,6 +125,11 @@ TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
# scam/fake. Leave empty to use the built-in English text. # scam/fake. Leave empty to use the built-in English text.
TELESRV_SCAM_WARNING= TELESRV_SCAM_WARNING=
TELESRV_FAKE_WARNING= TELESRV_FAKE_WARNING=
# Usernames a user/channel can never self-service claim (account.updateUsername,
# channels.updateUsername) -- brand-adjacent or staff-sounding words, plus
# your own real handle if you want it protected too. Comma-separated, not
# case-sensitive. The admin panel can still assign any of these on purpose.
TELESRV_RESERVED_USERNAMES=owpengram,admin,administrator,support,staff,moderator,official,root,owner
## Admin Panel -- Login and access for the web-based admin dashboard. ## Admin Panel -- Login and access for the web-based admin dashboard.
@ -117,8 +147,13 @@ TELESRV_ADMIN_SESSION_KEY=
# the admin panel entirely; set to a loopback address (127.0.0.1:...) to # the admin panel entirely; set to a loopback address (127.0.0.1:...) to
# enable it without exposing it outside this machine. # enable it without exposing it outside this machine.
TELESRV_ADMIN_API_ADDR= TELESRV_ADMIN_API_ADDR=
# Address the admin panel's own web UI listens on. # Address the admin panel's own web UI listens on. 0.0.0.0 so a fresh
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600 # install is reachable right away from wherever you're setting it up from --
# a VPS you're provisioning from across the world included -- without an SSH
# tunnel just to see the first-run wizard. Narrow it to a loopback or LAN
# address once you're done if you'd rather it not be open to the internet;
# the login itself still needs the password (or token) below either way.
TELESRV_ADMIN_UI_ADDR=0.0.0.0:2600
# Permissions granted to an Admin UI session that logged in with # Permissions granted to an Admin UI session that logged in with
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every # TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
# permission and is the default, so enabling RBAC never locks an operator out of # permission and is the default, so enabling RBAC never locks an operator out of
@ -269,14 +304,63 @@ TELESRV_S3_USE_SSL=false
# MinIO needs this on (bucket in the URL path); AWS S3 does not. # MinIO needs this on (bucket in the URL path); AWS S3 does not.
TELESRV_S3_PATH_STYLE=true TELESRV_S3_PATH_STYLE=true
# Reject new uploads once storage is nearly full, instead of letting the disk # Reject new uploads once storage is nearly full, instead of letting the disk
# fill up. Thresholds live in the Advanced section below. # fill up. Thresholds are the three fields right below.
TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE=true TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE=true
# Automatically delete old media once it's no longer referenced by any # localfs: reject new uploads once real free disk bytes fall below this; <=0 disables.
# message, profile photo, or sticker set (never deletes media still visible TELESRV_STORAGE_MIN_FREE_BYTES=1073741824
# in a conversation). Off by default -- storage usage is tracked and shown # Reject new uploads once total tracked blob bytes would exceed this. The only
# in the admin panel either way; enable this once you're comfortable with # meaningful "low space" signal on the s3 backend (no OS free-space concept);
# what it will reclaim. Retention age lives in the Advanced section below. # optional soft cap on localfs too. <=0 disables.
TELESRV_STORAGE_RETENTION_ENABLE=false TELESRV_STORAGE_MAX_TOTAL_BYTES=0
# Reject a single upload once its total assembled size (sum of all its parts)
# would exceed this. <=0 disables this check (the protocol's own part-count
# ceiling of ~4GB still applies). Must not exceed that ceiling.
TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES=0
# Storage retention sweep mode: "off" (default, nothing auto-deleted --
# storage usage is still tracked and shown in the admin panel either way),
# "orphan" (safe: deletes a document/photo's blob only once it's no longer
# referenced by any message/profile photo/sticker set), or "hard"
# (aggressive: deletes a document/photo's blob once it's old enough,
# REGARDLESS of whether it's still referenced -- old media in active
# conversations will show as unavailable).
TELESRV_STORAGE_RETENTION_MODE=off
# How long a document/photo must have had zero references before the sweep
# deletes it in "orphan" mode above -- or how old the media itself is before
# "hard" mode deletes its bytes regardless of references. Ignored when the
# mode above is "off". The sweep itself runs alongside every other retention
# check on the shared TELESRV_RETENTION_INTERVAL/TELESRV_RETENTION_BATCH
# cadence (Advanced section below).
TELESRV_STORAGE_RETENTION_MAX_AGE=720h
# Optional per-category overrides of the shared age above (Photo/Video/Round
# Video/Gif/Music/Voice/File/Avatar) -- each empty/unset value falls back to
# TELESRV_STORAGE_RETENTION_MAX_AGE. The mode switch above still applies to
# all of them; these only let one category expire sooner or later than the
# rest (e.g. purge voice notes after a week but keep files for a year).
TELESRV_STORAGE_RETENTION_MAX_AGE_PHOTO=
TELESRV_STORAGE_RETENTION_MAX_AGE_VIDEO=
TELESRV_STORAGE_RETENTION_MAX_AGE_ROUND_VIDEO=
TELESRV_STORAGE_RETENTION_MAX_AGE_GIF=
TELESRV_STORAGE_RETENTION_MAX_AGE_MUSIC=
TELESRV_STORAGE_RETENTION_MAX_AGE_VOICE=
TELESRV_STORAGE_RETENTION_MAX_AGE_FILE=
TELESRV_STORAGE_RETENTION_MAX_AGE_AVATAR=
# Once enabled, actively reclaims space once total physical storage exceeds
# TELESRV_STORAGE_MAX_TOTAL_BYTES above: the oldest files (regardless of
# category/age) are purged the same way "hard" retention mode purges blob
# bytes, until back under budget. Independent of the retention mode switch
# above -- can run even when that's "off". Default false: TELESRV_STORAGE_MAX_
# TOTAL_BYTES otherwise only ever blocks new uploads, never reclaims from
# existing ones.
TELESRV_STORAGE_EVICTION_ENABLE=false
# Secret-chat encrypted files (photos/documents sent in a secret chat) are
# opaque ciphertext the server can't inspect -- none of the retention/
# eviction settings above ever touch them, so they otherwise accumulate
# forever. Secret chats are single-device on both ends (no multi-device
# sync), so once the one recipient device that will ever ask for a file has
# downloaded it in full, the server has no further reason to keep it.
# Default true: set to false if you'd rather keep the ciphertext around
# regardless (it still can't be read without the secret chat's own key).
TELESRV_SECRET_CHAT_DELETE_FILE_AFTER_DOWNLOAD=true
# ============================================================================== # ==============================================================================
@ -312,7 +396,7 @@ TELESRV_MTPROTO_RPC_MAX_INFLIGHT=32
TELESRV_MTPROTO_RPC_QUEUE_SIZE=64 TELESRV_MTPROTO_RPC_QUEUE_SIZE=64
TELESRV_MTPROTO_RPC_TIMEOUT=30s TELESRV_MTPROTO_RPC_TIMEOUT=30s
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256 TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192 TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=32768
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
# Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately; # Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
# 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget. # 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
@ -410,23 +494,14 @@ TELESRV_DEFAULT_STICKER_SET_ID=0
# imported once (matched by filename); renaming a file re-imports it as a new # imported once (matched by filename); renaming a file re-imports it as a new
# entry. Missing directory is skipped, not an error. # entry. Missing directory is skipped, not an error.
TELESRV_GIF_SEED_DIR=data/gifs TELESRV_GIF_SEED_DIR=data/gifs
# Admin-editable server name/description/icon (Server Settings in the admin
# panel), served over /owpengram/server-info + /owpengram/server-icon and
# read fresh on every request -- editing them takes effect with no restart.
TELESRV_IDENTITY_DIR=data/identity
# Storage low-space guard thresholds (master toggle is TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE above). # How often the cached free-space/usage gauge behind the low-space guard
# localfs: reject new uploads once real free disk bytes fall below this; <=0 disables. # (thresholds now live in the Storage & Media section above) refreshes.
TELESRV_STORAGE_MIN_FREE_BYTES=1073741824
# Reject new uploads once total tracked blob bytes would exceed this. The only
# meaningful "low space" signal on the s3 backend (no OS free-space concept);
# optional soft cap on localfs too. <=0 disables.
TELESRV_STORAGE_MAX_TOTAL_BYTES=0
# How often the cached free-space/usage gauge behind the guard above refreshes.
TELESRV_STORAGE_USAGE_REFRESH_INTERVAL=1m TELESRV_STORAGE_USAGE_REFRESH_INTERVAL=1m
# Storage retention sweep tuning (master toggle is TELESRV_STORAGE_RETENTION_ENABLE above).
# How long a document/photo must have had zero references before the sweep
# deletes it -- not how old the media itself is, and it never touches media
# still referenced by a live message/profile-photo/sticker-set. The sweep
# itself runs alongside every other retention check on the shared
# TELESRV_RETENTION_INTERVAL/TELESRV_RETENTION_BATCH cadence above.
TELESRV_STORAGE_RETENTION_MAX_AGE=720h
# New-account perks: free Telegram Premium months. # New-account perks: free Telegram Premium months.
TELESRV_PREMIUM_GRANT_MONTHS=3 TELESRV_PREMIUM_GRANT_MONTHS=3
@ -465,6 +540,10 @@ TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
# rows. Interval must be positive; batch must be 1..500. # rows. Interval must be positive; batch must be 1..500.
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
TELESRV_VERIFICATION_NOTIFY_BATCH=50 TELESRV_VERIFICATION_NOTIFY_BATCH=50
TELESRV_BROADCAST_WORKER_INTERVAL=3s
TELESRV_BROADCAST_WORKER_LEASE=30s
TELESRV_BROADCAST_MATERIALIZE_BATCH=200
TELESRV_BROADCAST_DELIVERY_BATCH=50
# Applications one applicant may keep open at once; 0 disables the cap, maximum # Applications one applicant may keep open at once; 0 disables the cap, maximum
# is 50. # is 50.
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3 TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3

15
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: owpengram
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

130
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,130 @@
name: Build and Release
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
ci:
name: CI
uses: ./.github/workflows/ci.yml
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: windows
goarch: amd64
- goos: windows
goarch: arm64
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: cmd/telesrv-admin/web/package-lock.json
- name: Build admin web assets
working-directory: cmd/telesrv-admin/web
run: |
npm ci
npm run build
- name: Download Go modules
run: go mod download
- name: Build binaries
env:
CGO_ENABLED: '0'
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
mkdir -p dist
suffix=""
if [ "${GOOS}" = "windows" ]; then
suffix=".exe"
fi
go build \
-trimpath \
-ldflags="-s -w" \
-o "dist/gramsrv-${GOOS}-${GOARCH}${suffix}" \
./cmd/telesrv
go build \
-trimpath \
-ldflags="-s -w" \
-o "dist/gramsrv-admin-${GOOS}-${GOARCH}${suffix}" \
./cmd/telesrv-admin
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: gramsrv-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
retention-days: 7
release:
name: Publish GitHub Release
needs:
- ci
- build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
pattern: gramsrv-*
path: dist
merge-multiple: true
- name: Generate combined checksums
working-directory: dist
run: |
sha256sum gramsrv-* > SHA256SUMS
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
dist/* \
--title "${{ github.ref_name }}" \
--generate-notes

180
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,180 @@
name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
go-test:
name: Go tests
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Download Go modules
run: go mod download
- name: Test
run: go test ./... -count=1
admin-web:
name: Admin web build
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: cmd/telesrv-admin/web/package-lock.json
- name: Install dependencies
working-directory: cmd/telesrv-admin/web
run: npm ci
- name: Build
working-directory: cmd/telesrv-admin/web
run: npm run build
grammystore:
name: Grammy store bot
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: cmd/bots/grammystore/package-lock.json
- name: Install dependencies
working-directory: cmd/bots/grammystore
run: npm ci
- name: Check syntax
working-directory: cmd/bots/grammystore
run: npm run check
- name: Test
working-directory: cmd/bots/grammystore
run: npm test
docker-smoke:
name: Docker main topology smoke
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Generate isolated environment
run: ./scripts/new-docker-env.sh --advertise-ip 127.0.0.1
- name: Validate deployment inputs
run: |
docker compose version
sh -n scripts/new-docker-env.sh
bash -n scripts/start-docker.sh
sh -n deploy/docker/docker-entrypoint.sh
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
bridge=("${compose[@]}" -f deploy/docker/compose.bridge-network.yaml)
"${compose[@]}" config --quiet
"${compose[@]}" config --format json | python3 -c 'import json,sys; s=json.load(sys.stdin)["services"]; assert s["server"].get("network_mode") == "host"; assert s["admin"].get("network_mode") == "host"; assert len(s["server"].get("ports", [])) == 0; assert len(s["admin"].get("ports", [])) == 0; assert str(s["server"]["environment"]["TELESRV_TURN_RELAY_MAX_PORT"]) == "12999"'
"${bridge[@]}" config --quiet
"${bridge[@]}" config --format json | python3 -c 'import json,sys; c=json.load(sys.stdin); s=c["services"]; assert s["server"].get("network_mode") != "host"; assert s["admin"].get("network_mode") != "host"; assert len(s["server"].get("ports", [])) == 69; assert len(s["admin"].get("ports", [])) == 1; assert "admin_host_access" in s["admin"]["networks"]; assert not c["networks"]["admin_host_access"].get("internal", False); assert str(s["server"]["environment"]["TELESRV_TURN_RELAY_MAX_PORT"]) == "12563"'
- name: Validate PowerShell launchers
shell: pwsh
run: |
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile("scripts/new-docker-env.ps1", [ref]$tokens, [ref]$errors)
if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile("scripts/start-docker.ps1", [ref]$tokens, [ref]$errors)
if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
- name: Build application images
run: |
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
"${compose[@]}" build --pull server admin
- name: Start and wait for readiness
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml up -d --no-build --wait --wait-timeout 600
- name: Verify runtime and media listeners
run: |
compose=(docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml)
for service in server admin; do
container_id="$("${compose[@]}" ps --quiet "$service")"
test -n "$container_id"
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "10001:10001"
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
test "$(docker inspect --format '{{json .HostConfig.CapDrop}}' "$container_id")" = '["ALL"]'
test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' "$container_id")" = "1024"
test "$(docker inspect --format '{{json .HostConfig.SecurityOpt}}' "$container_id")" = '["no-new-privileges:true"]'
test "$(docker inspect --format '{{.HostConfig.NetworkMode}}' "$container_id")" = "host"
done
curl --fail --silent --show-error http://127.0.0.1:2401/healthz | grep -qx ok
curl --fail --silent --show-error http://127.0.0.1:2600/ >/dev/null
timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/2400'
python3 - <<'PY'
import os
import socket
import struct
transaction_id = os.urandom(12)
request = struct.pack("!HHI12s", 0x0001, 0, 0x2112A442, transaction_id)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.settimeout(5)
client.sendto(request, ("127.0.0.1", 12400))
response, _ = client.recvfrom(2048)
message_type, _, cookie = struct.unpack("!HHI", response[:8])
assert message_type == 0x0101, hex(message_type)
assert cookie == 0x2112A442, hex(cookie)
assert response[8:20] == transaction_id
PY
server_logs="$("${compose[@]}" logs --no-color server)"
case "$server_logs" in *"sfu listening"*) ;; *) echo "Embedded SFU did not become ready" >&2; exit 1 ;; esac
case "$server_logs" in *"turn listening"*) ;; *) echo "Embedded TURN did not become ready" >&2; exit 1 ;; esac
case "$server_logs" in *"live stream rtmp ingest listening"*) ;; *) echo "RTMP listener did not become ready" >&2; exit 1 ;; esac
- name: Show logs on failure
if: failure()
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml logs --no-color --tail 200
- name: Remove isolated stack
if: always()
run: docker compose -p telesrv-main-ci --project-directory deploy/docker --env-file deploy/docker/.env -f deploy/docker/compose.yaml down --volumes --remove-orphans

76
.github/workflows/container-images.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Publish main container images (manual)
on:
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: containers-main-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
name: Publish ${{ matrix.role }}
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- role: server
target: server-test
- role: admin
target: admin
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to GHCR
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image metadata
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.role }}
tags: |
type=raw,value=main
type=sha,prefix=sha-
- name: Set build date
id: build
shell: bash
run: echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Build and publish
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VCS_REF=${{ github.sha }}
VCS_BRANCH=${{ github.ref_name }}
VCS_TREE_STATE=clean
BUILD_DATE=${{ steps.build.outputs.date }}
cache-from: type=gha,scope=main-${{ matrix.role }}
cache-to: type=gha,mode=max,scope=main-${{ matrix.role }}

7
.gitignore vendored
View file

@ -10,6 +10,9 @@ node_modules/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# install-prereqs.sh / owpengram-server.sh create this for the panel's packages.
# Untracked and unignored, it was one `git clean -fd` away from being deleted.
/.venv/
# 本地环境 / 密钥server RSA private key 必须持久化,但禁止入库) # 本地环境 / 密钥server RSA private key 必须持久化,但禁止入库)
.env .env
@ -35,6 +38,10 @@ tmp/
.db_naming .db_naming
.server_panel.json .server_panel.json
# Local-only destructive dev tool (deletes .env, data/, Docker volumes) --
# never belongs in the repo.
/wipe-server.bat
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/

32
Containerfile Normal file
View file

@ -0,0 +1,32 @@
FROM docker.io/library/golang:1.25 AS build
# Build metadata for telesrv's startup log (git_commit/git_branch/... in
# cmd/telesrv/buildinfo.go). .containerignore excludes .git, so go build's
# automatic VCS stamping sees no repo; pass these in explicitly, e.g.:
# podman build \
# --build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
# --build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
# --build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
# --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
# -t owpengram-server -f Containerfile .
ARG GIT_COMMIT=unknown
ARG GIT_BRANCH=unknown
ARG GIT_TREE_STATE=unknown
ARG BUILD_TIME=unknown
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-X main.gitCommit=${GIT_COMMIT} -X main.gitBranch=${GIT_BRANCH} -X main.gitTreeState=${GIT_TREE_STATE} -X main.buildTime=${BUILD_TIME}" \
-o /out/gramsrv ./cmd/telesrv
RUN CGO_ENABLED=0 go build -trimpath -o /out/telesrv-admin ./cmd/telesrv-admin
RUN CGO_ENABLED=0 go build -trimpath -o /out/createuser ./cmd/createuser
FROM docker.io/library/alpine:3.20
RUN apk add --no-cache ca-certificates tzdata ffmpeg
WORKDIR /app
COPY --from=build /out/gramsrv /app/gramsrv
COPY --from=build /out/telesrv-admin /app/telesrv-admin
COPY --from=build /out/createuser /app/createuser
COPY --from=build /src/data/langpack /app/data/langpack
COPY --from=build /src/data/sticker-seed /app/data/sticker-seed
EXPOSE 2398 2600
ENTRYPOINT ["/app/gramsrv"]

91
Dockerfile Normal file
View file

@ -0,0 +1,91 @@
# syntax=docker/dockerfile:1.7
ARG GO_IMAGE=golang:1.25-alpine@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59
ARG ALPINE_IMAGE=alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build-base
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache ca-certificates git
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY cmd/ ./cmd/
COPY deploy/ ./deploy/
COPY internal/ ./internal/
ENV CGO_ENABLED=0
FROM build-base AS build-server
ARG VCS_REF=unknown
ARG VCS_BRANCH=unknown
ARG VCS_TREE_STATE=unknown
ARG BUILD_DATE=unknown
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath \
-ldflags="-s -w -X main.gitCommit=${VCS_REF} -X main.gitBranch=${VCS_BRANCH} -X main.gitTreeState=${VCS_TREE_STATE} -X main.buildTime=${BUILD_DATE}" \
-o /out/telesrv ./cmd/telesrv
FROM build-base AS build-admin
RUN apk add --no-cache nodejs npm
WORKDIR /src/cmd/telesrv-admin/web
RUN --mount=type=cache,target=/root/.npm npm ci && npm run build
WORKDIR /src
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/telesrv-admin ./cmd/telesrv-admin
FROM ${ALPINE_IMAGE} AS runtime-base
ARG VCS_REF=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="gramsrv" \
org.opencontainers.image.description="Telegram-like MTProto server" \
org.opencontainers.image.source="https://github.com/iamxvbaba/gramsrv" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.created="${BUILD_DATE}"
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S -g 10001 telesrv \
&& adduser -S -D -H -u 10001 -G telesrv telesrv \
&& install -d -o telesrv -g telesrv -m 0750 /app /var/lib/telesrv
COPY --chmod=0555 deploy/docker/docker-entrypoint.sh /usr/local/bin/telesrv-container-entrypoint
WORKDIR /app
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/telesrv-container-entrypoint"]
FROM runtime-base AS server
USER root
RUN apk add --no-cache ffmpeg openssl \
&& install -d -o telesrv -g telesrv -m 0750 \
/var/lib/telesrv/blobs \
/var/lib/telesrv/blob-staging \
/var/lib/telesrv/maptiles \
/var/lib/telesrv/livestream
COPY --from=build-server /out/telesrv /usr/local/bin/telesrv
COPY --chown=telesrv:telesrv data/langpack/ /usr/share/telesrv/langpack/
USER 10001:10001
EXPOSE 2398 2400 2401 2599 12399/udp 12400/udp
CMD ["telesrv"]
FROM server AS server-test
USER root
RUN install -d -o telesrv -g telesrv -m 0755 /usr/share/telesrv/keys
COPY --chown=telesrv:telesrv --chmod=0444 deploy/docker/assets/test-server-rsa.pub /usr/share/telesrv/keys/test-server-rsa.pub
COPY --chown=telesrv:telesrv --chmod=0444 deploy/docker/assets/test-server-rsa.pem.b64 /usr/share/telesrv/keys/test-server-rsa.pem.b64
USER 10001:10001
FROM runtime-base AS admin
COPY --from=build-admin /out/telesrv-admin /usr/local/bin/telesrv-admin
EXPOSE 2600
CMD ["telesrv-admin"]

241
README.md
View file

@ -8,8 +8,8 @@
The protocol stack is built on the published The protocol stack is built on the published
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module [`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
(`v1.1.0`), using a canonical Layer 228 schema with sparse `tlprofile` (`v1.3.2`), using a canonical Layer 229 schema with sparse `tlprofile`
exact Layer 225-228 compatibility profiles. exact Layer 225-229 compatibility profiles.
If you are looking for a **Telegram server**, **MTProto server**, If you are looking for a **Telegram server**, **MTProto server**,
**Telegram backend**, **Telegram clone server**, or **self-hosted **Telegram backend**, **Telegram clone server**, or **self-hosted
@ -21,7 +21,10 @@ in Go. Run it on your own network for a private, closed setup, or on a VPS to
be reachable anywhere in the world. Your data, your keys, your rules — no be reachable anywhere in the world. Your data, your keys, your rules — no
cloud, no lock-in, no censorship. cloud, no lock-in, no censorship.
> 🔗 Implements **MTProto API layer 228**. > 🔗 Implements **MTProto API layers 225-229** — a client is admitted on the
> exact layer it announces, so older builds keep working after the server moves
> forward. The running server reports its version, the layers it accepts, and
> its build in the admin panel sidebar.
`OwpenGram Server` is independent and unofficial. It is not affiliated with, endorsed by, `OwpenGram Server` is independent and unofficial. It is not affiliated with, endorsed by,
or sponsored by Telegram or the official Telegram team. or sponsored by Telegram or the official Telegram team.
@ -36,6 +39,9 @@ or sponsored by Telegram or the official Telegram team.
- 🛡️ **Censorship-resistant** — no central authority can shut you down. - 🛡️ **Censorship-resistant** — no central authority can shut you down.
- ⚙️ **Single binary** — one Go program prepares keys, runs migrations, serves - ⚙️ **Single binary** — one Go program prepares keys, runs migrations, serves
MTProto, and dispatches updates and background workers. MTProto, and dispatches updates and background workers.
- 📦 **One command to install** — the launcher installs the prerequisites
it needs (Go, Python, Docker, OpenSSL), brings the stack up, and hands you
a browser setup wizard.
- 🆓 **Free & open source** — Apache-2.0, audit and extend it freely. - 🆓 **Free & open source** — Apache-2.0, audit and extend it freely.
## 🎯 What works today ## 🎯 What works today
@ -50,6 +56,15 @@ or sponsored by Telegram or the official Telegram team.
- 🔑 Self-hosted "Log in with Telegram" (OpenID Connect) and passkey sign-in - 🔑 Self-hosted "Log in with Telegram" (OpenID Connect) and passkey sign-in
- 🌐 Message translation and AI-assisted compose - 🌐 Message translation and AI-assisted compose
- 📇 Contacts, dialogs sync, chat folders, public link landing pages - 📇 Contacts, dialogs sync, chat folders, public link landing pages
- 🔎 **Self-configuring clients** — "Add Server" needs only `host:port`; the
server publishes its DC id, RSA key and identity over a well-known HTTP path
- 👥 **Multi-operator admin panel** — named operator accounts with scoped
permissions, instead of one shared password
- 🗄️ **Storage management** — usage breakdown, retention rules, and guarded
purge of orphaned or expired media
- 👋 Welcome messages and login-code templates you can edit from the panel
- 🧙 **First-run web setup wizard** — server identity, public address, Bot API
and your operator account, then a restart, all from the browser
- 🖥️ Admin API and web UI for operations, plus a TUI server panel to run it all - 🖥️ Admin API and web UI for operations, plus a TUI server panel to run it all
<details> <details>
@ -57,7 +72,7 @@ or sponsored by Telegram or the official Telegram team.
| Status | Feature | What works today | | Status | Feature | What works today |
|---|---|---| |---|---|---|
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 228, and sparse exact Layer 225-228 compatibility profiles. | | ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 229, sparse exact Layer 225-229 compatibility profiles, and a same-port HTTP side that serves WebSocket transport plus the `/owpengram/server-info` and `/owpengram/server-icon` self-configuration endpoints. |
| ✅ | Login and accounts | Development login code, configurable external code delivery (SMS webhook or SMTP), login email as a second factor, email-as-identity sign-up (no phone number needed), sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, WebAuthn passkey sign-in, and a self-hosted Telegram Login (OpenID Connect) provider for third-party sites. | | ✅ | Login and accounts | Development login code, configurable external code delivery (SMS webhook or SMTP), login email as a second factor, email-as-identity sign-up (no phone number needed), sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, WebAuthn passkey sign-in, and a self-hosted Telegram Login (OpenID Connect) provider for third-party sites. |
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. | | ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. | | ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
@ -72,21 +87,24 @@ or sponsored by Telegram or the official Telegram team.
| ✅ | Collectible usernames and verification | Fragment-style NFT/collectible usernames (mint, transfer, activate/deactivate), the official platform-checkmark flow (`@verifybot`), and a third-party bot-verification mark mechanism (`@marksbot`, icon + description before a name) — the latter is experimental and hidden by default. | | ✅ | Collectible usernames and verification | Fragment-style NFT/collectible usernames (mint, transfer, activate/deactivate), the official platform-checkmark flow (`@verifybot`), and a third-party bot-verification mark mechanism (`@marksbot`, icon + description before a name) — the latter is experimental and hidden by default. |
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. | | ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. | | ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
| ✅ | Admin and operations | Admin API/UI backend, per-account freeze (admin-set read-only restriction, advertised to the client via appConfig), broadcast messaging (announce from the official account to every user or a picked list), shared-device detection across accounts, RBAC-scoped admin API tokens, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, load-test helpers, and a bundled TUI server panel (setup wizard, start/stop/restart, one-click update via `git pull` + rebuild, live logs, `.env` editor) as an alternative to manual builds. | | ✅ | Admin and operations | Admin API/UI backend, a first-run web setup wizard (server identity, public network fields, optional Bot API gateway, first operator account), named operator accounts with per-section permissions and a wildcard "full access" grant, per-account freeze (admin-set read-only restriction, advertised to the client via appConfig), broadcast messaging (announce from the official account to every user or a picked list), editable welcome and login-code message templates, storage management (usage breakdown, retention rules, guarded purge), shared-device detection across accounts, RBAC-scoped admin API tokens, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, load-test helpers, one-click update with a dry run before it applies, and a bundled TUI server panel as an alternative to the web UI. |
| ✅ | Desktop, Android, iOS, and Web focus | Telegram Desktop is the primary target, with Android, iOS, and Web compatibility paths actively covered by the same server. | | ✅ | Desktop, Android, iOS, and Web focus | Telegram Desktop is the primary target, with Android, iOS, and Web compatibility paths actively covered by the same server. |
Some items are compatibility-first or experimental, but they are real open Some items are compatibility-first or experimental, but they are real open
server code, not hidden product-only features. server code, not hidden product-only features.
</details> </details>
## 🚀 Want to see it first?
You do not have to run a server to try OwpenGram. We keep a **public server
live**, and it ships inside both clients as a ready-made entry — install a
client, pick **OwpenGram** on the server-selection screen, sign in. Nothing to
configure.
Come back here when you want that server to be yours.
## ⚡ Quick Start ## ⚡ Quick Start
Requirements:
- **Go 1.25+**
- **Docker** (or Docker Desktop), for PostgreSQL and Redis
- OpenSSL, to export the server's RSA public key for the client's "Add Server" dialog
**1. Clone the repository** **1. Clone the repository**
```bash ```bash
@ -94,13 +112,56 @@ git clone https://github.com/owpengram/owpengram-server.git
cd owpengram-server cd owpengram-server
``` ```
**2. Start the infrastructure** (PostgreSQL + Redis) **2. Run the launcher**
```bash
./owpengram-server.sh # Linux
```
```powershell
.\owpengram-server.bat # Windows
```
The launcher checks what the server needs — Go 1.25+, Python 3, Docker,
OpenSSL — and **installs whatever is missing** instead of handing you a
shopping list: `scripts/install-prereqs.sh` on Arch and Ubuntu/Debian (asks for
root once, then works unattended) or `scripts/install-prereqs.ps1` on Windows
via winget. Run either directly with `--dry-run` to see what it would install
without touching anything.
> Docker on Windows is the one thing the script will not install for you: its
> containers are Linux images, so the daemon needs Docker Desktop's WSL2
> backend. The launcher reports it with a link instead of starting it.
**3. Answer the first-run form**
With the prerequisites in place the launcher opens the server panel. On a fresh
clone it shows a short form instead of the menu — only the values that need a
human decision, with `.env.example` defaults for everything else; the admin API
token and session key are generated for you. Confirm it and the panel writes
`.env`, starts PostgreSQL/Redis, builds both binaries, runs them, and shows the
admin panel address and password ready to copy.
**4. Finish setup in the browser**
Open that address. On a fresh install the panel opens a **web setup wizard**
that walks through the server name, description and icon, the public address
clients will connect to, the optional Bot API gateway, and your own operator
account — then restarts the server so it all takes effect. Nothing has to be
hand-edited to get going.
<details>
<summary><b>🔧 Prefer to do it manually? (click to expand)</b></summary>
Requirements: **Go 1.25+**, **Docker** (or Docker Desktop) for PostgreSQL and
Redis, and OpenSSL.
**Start the infrastructure** (PostgreSQL + Redis)
```powershell ```powershell
docker compose -f deploy/docker-compose.yml up -d docker compose -f deploy/docker-compose.yml up -d
``` ```
**3. Build and run the server** **Build and run the server**
Windows (PowerShell): Windows (PowerShell):
@ -123,42 +184,86 @@ workers in the same process.
> **Default local login code:** `12345` — change it before any real use! > **Default local login code:** `12345` — change it before any real use!
> 💡 **Prefer a menu over the command line?** Steps 2 and 3 above (Docker Without the web wizard you also have to fill in `.env` yourself; see
> infrastructure, build, run) can be done through the bundled **TUI server "⚙️ Configuration" below and [`.env.example`](.env.example).
> panel** instead — see "🖥️ Server Panel" right below.
### 🖥️ Server Panel (optional) </details>
A cross-platform interactive TUI wraps the steps above — Docker naming ### 🖥️ Server Panel
migration, `docker compose up`, `go build`, and launching both
`owpengram-server` and `owpengram-admin-panel` — behind a menu, so you don't
re-run commands from scratch every time.
```bash The launcher from step 2 is also a cross-platform interactive TUI: once the
./owpengram-server.sh # Linux/macOS prerequisites are in place it drops into a menu that wraps everything above —
``` Docker naming migration, `docker compose up`, `go build`, and launching both
```powershell `owpengram-server` and `owpengram-admin-panel` — so you don't re-run commands
.\owpengram-server.bat # Windows from scratch every time.
```
Both launchers check prerequisites first (Go, Python 3, and the panel's own
dependencies via `tui-panel/requirements-panel.txt`), then start the panel.
What it does: What it does:
- 🧙 **First-run setup wizard** — walks through the required `.env` values - 🧙 **First-run setup** — the short `.env` form from step 3. It is the
before the first start. only thing the panel offers on a fresh clone; once `.env` exists the menu
below replaces it, and later changes go through the `.env` editor.
- ▶️ **Start / Stop / Restart** — launches `owpengram-server` and - ▶️ **Start / Stop / Restart** — launches `owpengram-server` and
`owpengram-admin-panel` as detached background processes; closing the panel `owpengram-admin-panel` as detached background processes; closing the panel
does **not** stop them, only "Stop" does. Reopening the panel later picks does **not** stop them, only "Stop" does. Reopening the panel later picks
the same processes back up and reports live status. the same processes back up and reports live status.
- ⬆️ **Update**`git pull --ff-only`, rebuilds both binaries, restarts them, - ⬆️ **Update**`git pull --ff-only`, rebuilds both binaries, restarts them,
and re-execs the panel itself so it also picks up any change to its own and re-execs the panel itself so it also picks up any change to its own
code — one menu action instead of a manual pull/build/restart sequence. code — one menu action instead of a manual pull/build/restart sequence. The
web panel has the same action, with a dry run that reports what an update
would do before anything is applied.
- 📜 **Live log viewer** — tail either binary's log, or both in a split view. - 📜 **Live log viewer** — tail either binary's log, or both in a split view.
- ⚙️ **`.env` editor** — edit configuration from inside the panel, grouped by - ⚙️ **`.env` editor** — edit configuration from inside the panel, grouped by
feature, without hand-editing the file. feature, without hand-editing the file.
### 🏷️ Version and build
The admin panel's sidebar footer identifies exactly what is running:
```text
Version: O7
API layers: 225-229
Build: 7ad68c3
```
- **Version** — the OwpenGram server release line (`O7`).
- **API layers** — every MTProto TL schema layer this binary can talk, read
straight from the compatibility profiles rather than hardcoded.
- **Build** — the short commit the binary was built from, stamped
automatically by Go's VCS info (no special build flags needed); a trailing
`+` means it was built from a working tree with uncommitted changes. Hover
it for the full hash.
Quote the `Version` / `Build` pair in bug reports — it pins the exact code,
which a release tag alone does not.
### 👥 Operators and permissions
The panel is no longer one shared password. Its **Operators** page creates
named accounts, each with its own login and an explicit set of permissions,
granted per section and per level — for example `accounts.read`
vs `accounts.manage`, `storage.read` vs `storage.manage`, `broadcasts.send`,
`moderation.review`, `server.manage`, `admins.manage`. An operator only sees
the sections they hold a permission for; reaching anything else lands on a
clear 403 screen instead of an empty page.
A **Full access** checkbox at the top of the permission grid grants the `*`
wildcard — everything, including permissions added by future releases. It is
deliberately separate from the grid (which is disabled while it is on), so
"this person is a full admin" and "this person may do these six things" never
get confused.
The one thing the panel will not let you do is lock yourself out: removing
`admins.manage` from the last enabled operator who holds it — by editing,
disabling, or deleting them — is refused, whether the grant is explicit or via
the wildcard. There is always someone left who can manage operators.
Your first operator is created by the web setup wizard. Before it exists, the
panel lets you in with a password it generates for that one purpose — and stops
accepting that generated password the moment the wizard finishes. A password you
set yourself (in Server Settings, or `TELESRV_ADMIN_UI_PASSWORD` in `.env`)
keeps working as a full-access break-glass login alongside the operator
accounts, so leave it unset or treat it like a root password.
### ⚙️ Configuration ### ⚙️ Configuration
[`.env.example`](.env.example) is the complete configuration reference — every [`.env.example`](.env.example) is the complete configuration reference — every
@ -256,6 +361,17 @@ Related toggles (defaults in `.env.example`'s Advanced section): a low-space
guard that rejects new uploads once storage nears full, and automatic guard that rejects new uploads once storage nears full, and automatic
cleanup of media no longer referenced by any message. cleanup of media no longer referenced by any message.
**Storage management in the panel.** The admin panel's Storage page puts the
rest of this behind a UI: a usage breakdown per media type, an upload size cap
(`TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES`, validated against the protocol's own
upload ceiling), retention rules that can expire media globally or per type
(`TELESRV_STORAGE_RETENTION_MODE` and the `TELESRV_STORAGE_RETENTION_MAX_AGE*`
family), and a "danger zone" for manual purges by media category and age.
Destructive panel actions — purge, update, restart — all go through the same
three-step flow: type a reason, run a **dry run** that reports exactly what
would happen, then confirm. Nothing irreversible fires on a single click.
## 🔌 Ports to open ## 🔌 Ports to open
When deploying on a public server, open the following according to the When deploying on a public server, open the following according to the
@ -317,37 +433,47 @@ the public routes to it with HTTPS.
## 📱 Connect a client ## 📱 Connect a client
Use the OwpenGram clients, which have a built-in **Add Server** option on the Use the OwpenGram clients, which have a built-in **Add Server** option on the
server-selection screen at login — no source patching or custom build needed: server-selection screen at login — no source patching or custom build needed.
They also ship with our public server already in that list, so a client is
useful the moment it is installed, with or without a server of your own.
Both are forks of the official apps, kept on the same TL layer as the server and
rebased on the upstream release that introduced it:
- 🤖 [Android client](https://github.com/owpengram/owpengram-android-client) | Client | Upstream base | Upstream commit | TL layer |
- 💻 [Desktop client](https://github.com/owpengram/owpengram-desktop-client) |---|---|---|---|
| 💻 [Desktop](https://github.com/owpengram/owpengram-desktop-client) | Telegram Desktop `v7.2.2` | `7b4481b6941212bb9dbf08e533adea97947b0f44` | 229 |
| 🤖 [Android](https://github.com/owpengram/owpengram-android-client) | Telegram for Android `v12.10.1` | `62b56a07ca7e30e39f7fd00a6728d6bbd716ca1c` | 229 |
A stock Telegram client will not connect, since it only trusts Telegram's own A stock Telegram client will not connect, since it only trusts Telegram's own
DC list and RSA keys. DC list and RSA keys. The server's canonical layer is 229, with exact
compatibility profiles for layers 225-229 — so an older fork build keeps
working after the server moves forward. Locally that server is
`127.0.0.1:2398`, DC id `2`.
- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b` **All you need is `host:port`.** On the login screen open server selection →
- Canonical TL layer: 228 **Add Server** and type the address (e.g. `chat.example.com:2398`, or
- Exact compatibility profiles: Layer 225-228 `192.168.1.50:2398`). The client fetches `/owpengram/server-info` from the same
- Local DC: `127.0.0.1:2398`, DC id `2` port and fills in the rest by itself — RSA public key, DC id, and the server's
name, description and icon as you set them in the setup wizard. No
`openssl`, no PEM copy-paste.
**1. Export your server's public key** The RSA key and DC id are still there under **Advanced** if you want to check
or override them. To get the key by hand — for an air-gapped machine, or to
verify what the client fetched — export it from the server's private key:
After the server generates `data/server_rsa.pem`, export the matching public ```bash
key as PEM:
```powershell
openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub
``` ```
**2. Add the server in the client** or just read the JSON the client reads:
On the login screen, open server selection → **Add Server**, and fill in: ```bash
curl http://your-server:2398/owpengram/server-info
```
- **Host** — your server's address (e.g. `192.168.1.50` or `chat.example.com`) > Self-configuration rides the same-port HTTP side that also serves the
- **Port**`2398` by default > WebSocket transport, so it needs `TELESRV_WEBSOCKET_ENABLE=true` (the
- **Main data center** — the DC id from `TELESRV_DC` (`2` by default) > default) and opens no extra port. With it off, fill in **Advanced** manually.
- **RSA Public Key** — paste the full contents of `data/server_rsa.pub`
(the `-----BEGIN RSA PUBLIC KEY-----...` PEM block) into the key field
## 🧪 Development: multi-device smoke test ## 🧪 Development: multi-device smoke test
@ -374,16 +500,21 @@ you changed `TELESRV_DEV_AUTH_CODE`. Recommended checks:
## 📂 Repository layout ## 📂 Repository layout
```text ```text
owpengram-server.sh/.bat one-command launcher (installs prerequisites, then the panel)
scripts/install-prereqs.* unattended prerequisite installers (Arch/Ubuntu, Windows)
cmd/telesrv/ server entrypoint cmd/telesrv/ server entrypoint
cmd/telesrv-admin/ admin backend and web UI cmd/telesrv-admin/ admin backend and embedded React web UI (incl. the setup wizard)
cmd/telesrv-update/ one-click update helper used by the panels
tui-panel/ interactive TUI server panel (setup, start/stop, update, logs, .env editor) tui-panel/ interactive TUI server panel (setup, start/stop, update, logs, .env editor)
deploy/ docker-compose (incl. MinIO), migrations, deploy helpers deploy/ docker-compose (incl. MinIO), migrations, deploy helpers
data/ bundled language packs and optional seed data data/ bundled language packs and optional seed data
internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend, server-info endpoints
internal/rpc/ TL router and client compatibility handlers internal/rpc/ TL router and client compatibility handlers
internal/app/ domain services internal/app/ domain services
internal/domain/ protocol-independent domain models internal/domain/ protocol-independent domain models
internal/store/ memory/postgres/redis storage backends internal/store/ memory/postgres/redis storage backends
internal/identity/ admin-editable server name, description, and icon
internal/botapi/ minimal HTTP Bot API gateway
internal/seed/ bundled seed catalog loaders internal/seed/ bundled seed catalog loaders
internal/sfu/ real-time SFU experiments internal/sfu/ real-time SFU experiments
internal/turnsrv/ TURN/STUN building blocks internal/turnsrv/ TURN/STUN building blocks

47
build.sh Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Build the owpengram-server container image (stamping the current git state into
# the binary - .containerignore excludes .git, so go build can't see the repo and
# the values are passed in here), then restart the owpengram-server and
# owpengram-admin systemd services so they pick up the freshly built image.
# Container creation/lifecycle beyond the pod itself is owned by those systemd
# units, not this script.
#
# Usage: ./build.sh [extra podman build args...]
# IMAGE=my/tag ./build.sh override the image tag (default: owpengram-server)
# POD=name ./build.sh override the pod name (default: owpengram)
# NO_DEPLOY=1 ./build.sh build the image only, don't restart the services
set -euo pipefail
cd "$(dirname "$0")"
IMAGE="${IMAGE:-owpengram-server}"
POD="${POD:-owpengram}"
podman build \
--build-arg GIT_COMMIT="$(git rev-parse HEAD)" \
--build-arg GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" \
--build-arg GIT_TREE_STATE="$(git diff --quiet && echo clean || echo dirty)" \
--build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t "$IMAGE" \
-f Containerfile \
"$@" \
.
if [ "${NO_DEPLOY:-0}" = "1" ]; then
echo "built $IMAGE (NO_DEPLOY=1, services unchanged)"
exit 0
fi
if ! podman pod exists "$POD"; then
echo "pod '$POD' does not exist - creating it"
podman pod create --name "$POD" \
-p 2398:2398 \
-p 127.0.0.1:2600:2600 \
-p 2400:2400 \
-p 2500:2500 \
-p 12399:12399/udp \
-p 12400:12400/udp \
-p 12500-12999:12500-12999/udp
fi
systemctl restart owpengram-server.service owpengram-admin.service
systemctl --no-pager status owpengram-server.service owpengram-admin.service

181
cmd/createuser/main.go Normal file
View file

@ -0,0 +1,181 @@
// Command createuser inserts a users row with an operator-chosen id, bypassing
// the normal users_id_seq auto-assignment. This works because users.id is
// GENERATED BY DEFAULT AS IDENTITY (not GENERATED ALWAYS) -- an explicit id in
// the INSERT is honored, the same mechanism ensureOfficialSystemUserWithDB
// (internal/store/postgres/message_send.go) already relies on to seed the
// built-in system accounts (ChatBot, BotFather, ...) at their fixed ids.
//
// Normal signup (auth.signUp) never lets a caller pick an id, so this exists
// purely for local/dev tooling -- reserving a specific low id (below
// OfficialSystemUserID=777000, say) for a test account.
//
// Usage:
//
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -phone "15550001234"
// createuser -id 1000 [-first-name Test] [-last-name User] [-username testuser] -email "test@example.com"
//
// -phone and -email are mutually exclusive: an email-signup account never
// stores the address in users.phone directly (see internal/domain/emailphone.go)
// -- it gets a synthetic "888"-prefixed display phone instead (the same one
// assignEmailSignupDisplayPhone hands a real email-signup account), with the
// real address recorded separately in signup_email.
//
// Reads TELESRV_POSTGRES_DSN the same way the server does (internal/config).
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/config"
"telesrv/internal/domain"
)
// maxEmailSignupPhoneAttempts bounds the display-phone collision-retry loop,
// mirroring internal/app/auth/service.go's own constant of the same name.
const maxEmailSignupPhoneAttempts = 20
func randomInt64() (int64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("rand: %w", err)
}
return int64(binary.LittleEndian.Uint64(b[:])), nil
}
func main() {
id := flag.Int64("id", 0, "user id to create (required)")
firstName := flag.String("first-name", "Test", "first name")
lastName := flag.String("last-name", "", "last name")
username := flag.String("username", "", "username, without @ (optional)")
phone := flag.String("phone", "", "phone number (optional; mutually exclusive with -email)")
email := flag.String("email", "", "email address for an email-signup account (optional; mutually exclusive with -phone)")
force := flag.Bool("force", false, "skip the reserved-id / sequence-collision safety checks")
flag.Parse()
if *id <= 0 {
fmt.Fprintln(os.Stderr, "createuser: -id is required and must be positive")
os.Exit(2)
}
if *phone != "" && *email != "" {
fmt.Fprintln(os.Stderr, "createuser: -phone and -email are mutually exclusive")
os.Exit(2)
}
if !*force {
if domain.IsSystemUserID(*id) {
fmt.Fprintf(os.Stderr, "createuser: %d is a reserved built-in system account id (see internal/domain/system.go) - refusing, pass -force to override\n", *id)
os.Exit(2)
}
if *id >= domain.UserIDSequenceBase {
fmt.Fprintf(os.Stderr, "createuser: %d is >= UserIDSequenceBase (%d) - a future organic signup could eventually collide with it; pass -force to proceed anyway (then consider bumping users_id_seq yourself)\n", *id, domain.UserIDSequenceBase)
os.Exit(2)
}
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: load config: %v\n", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.PostgresDSN)
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: connect: %v\n", err)
os.Exit(1)
}
defer pool.Close()
accessHash, err := randomInt64()
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
os.Exit(1)
}
displayPhone := *phone
signupEmail := ""
if *email != "" {
signupEmail = domain.NormalizeEmailForPhone(*email)
displayPhone, err = assignEmailSignupDisplayPhone(ctx, pool)
if err != nil {
fmt.Fprintf(os.Stderr, "createuser: %v\n", err)
os.Exit(1)
}
}
// phone/username/signup_email all sit under partial unique indexes that
// exclude '', so leaving any of them blank never collides with another
// blank-valued account.
row := pool.QueryRow(ctx, `
INSERT INTO users (id, access_hash, phone, signup_email, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, '')
ON CONFLICT (id) DO NOTHING
RETURNING id`,
*id, accessHash, displayPhone, signupEmail, *firstName, *lastName, *username)
var createdID int64
if err := row.Scan(&createdID); err != nil {
fmt.Fprintln(os.Stderr, describeInsertFailure(*id, *username, displayPhone, signupEmail, err))
os.Exit(1)
}
fmt.Printf("created user id=%d access_hash=%d first_name=%q last_name=%q username=%q phone=%q signup_email=%q\n",
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
}
// describeInsertFailure turns the INSERT's failure into a message naming the
// actual thing that collided, instead of "id already exists" for every case:
// ON CONFLICT (id) DO NOTHING only covers the id itself, so a duplicate
// username/phone/signup_email surfaces here as a distinct unique-violation
// error (pgx.ErrNoRows only means the id itself was the conflict).
func describeInsertFailure(id int64, username, phone, signupEmail string, err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "users_username_lower_unique_idx":
return fmt.Sprintf("createuser: username %q is already taken", username)
case "users_phone_unique_idx":
return fmt.Sprintf("createuser: phone %q is already in use", phone)
case "users_signup_email_lower_unique_idx":
return fmt.Sprintf("createuser: email %q is already in use by another account", signupEmail)
default:
return fmt.Sprintf("createuser: unique constraint %q violated: %v", pgErr.ConstraintName, err)
}
}
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Sprintf("createuser: id %d already exists", id)
}
return fmt.Sprintf("createuser: insert failed: %v", err)
}
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
// of the same name: pick a random "888"-prefixed display phone and re-roll on
// the astronomically unlikely collision with an existing account's phone.
func assignEmailSignupDisplayPhone(ctx context.Context, pool *pgxpool.Pool) (string, error) {
for range maxEmailSignupPhoneAttempts {
candidate, err := domain.NewEmailSignupDisplayPhone(domain.EmailPhonePrefix)
if err != nil {
return "", err
}
var exists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM users WHERE phone = $1)`, candidate).Scan(&exists); err != nil {
return "", fmt.Errorf("check display phone collision: %w", err)
}
if !exists {
return candidate, nil
}
}
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
}

View file

@ -0,0 +1,32 @@
package main
import (
"net/http"
"net/url"
"strconv"
)
// handleAddServerLinkAPI builds an owpg://addserver link (see the desktop
// and Android clients' handling of that scheme) carrying only this server's
// host and port, so an operator can hand it out as a ready-made "add my
// server" button/QR code.
//
// Deliberately carries nothing else -- no name, description, key, or DC.
// Anyone who can get a link in front of a user (a forum post, a chat
// message, an intercepted share) controls whatever it contains; if it also
// carried the RSA key, a link with a forged key pointed at an attacker's own
// host would be indistinguishable from a real one, and the client would
// trust it outright as "this server's identity" -- a real MITM vector, not
// a hypothetical one. host+port alone can't misrepresent anything: the
// client always fetches name/description/key/DC itself, straight from
// whatever actually answers at that address (ServerInfoPath), the same way
// it already does for a hand-typed address in the "Add Server" form.
func (s *server) handleAddServerLinkAPI(w http.ResponseWriter, r *http.Request) {
q := url.Values{}
q.Set("host", s.cfg.AdvertiseHost)
q.Set("port", strconv.Itoa(s.cfg.ServerPort))
writeJSON(w, http.StatusOK, map[string]any{
"link": "owpg://addserver?" + q.Encode(),
})
}

View file

@ -0,0 +1,154 @@
package main
import (
"context"
"errors"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
// bcryptCost is deliberately above bcrypt.DefaultCost (10). A panel login is a
// once-per-shift operation, so the extra time is invisible to an operator and
// meaningful to anyone working through a stolen dump of the table.
const bcryptCost = 12
// dummyBcryptHash is compared against when no account matched, so a login
// attempt costs the same whether or not the username exists. Without it the
// response time alone answers "is there an operator called X" -- the exact
// question the uniform error message refuses to answer.
//
// Value is bcrypt of a random string at bcryptCost; nothing authenticates
// against it.
const dummyBcryptHash = "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// breakGlassUsername is the name of the built-in operator backed by
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than by a database row.
//
// It is a real name rather than "no name" so audit lines read as an operator
// instead of as a blank, and so signing in as it is an explicit act: a blank
// username authenticates nothing.
//
// A database account may not take this name: authenticateLogin resolves it to
// the environment credential before ever consulting the table, so a row called
// "owpengram" would be shadowed -- and a name that silently does nothing is a
// trap. createAdminConsoleUser rejects it outright.
const breakGlassUsername = "owpengram"
// loginIdentity is who a successful login turns out to be.
type loginIdentity struct {
actor string
userID int64
epoch int32
permissions []string
}
// authenticateLogin resolves a login request to an identity, or reports
// failure. It never distinguishes its failure modes to the caller: every one
// of them is a plain false, so the handler cannot accidentally leak which.
func (s *server) authenticateLogin(ctx context.Context, req loginRequest) (loginIdentity, bool) {
username := strings.TrimSpace(req.Username)
// A username is always required. An empty one used to resolve to the
// break-glass operator, which made a blank field an unnamed second route to
// the highest-privilege login -- the sort of thing that does not belong in
// an admin panel. The operator must now be asked for by name.
if username == "" {
return loginIdentity{}, false
}
// The break-glass operator. Intentionally not backed by the database so it
// still works when the database does not.
if strings.EqualFold(username, breakGlassUsername) {
if !s.validSecret(req.Secret) {
return loginIdentity{}, false
}
return loginIdentity{actor: breakGlassUsername, permissions: s.cfg.Permissions}, true
}
if s.read == nil {
return loginIdentity{}, false
}
cred, err := s.read.AdminConsoleCredentialByUsername(ctx, username)
if err != nil {
if !errors.Is(err, errAdminUserNotFound) {
return loginIdentity{}, false
}
// Burn the same work an existing account would have cost before
// answering, so "no such user" and "wrong password" take equal time.
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(req.Secret))
return loginIdentity{}, false
}
if bcrypt.CompareHashAndPassword([]byte(cred.PasswordHash), []byte(req.Secret)) != nil {
return loginIdentity{}, false
}
// Checked after the hash comparison on purpose: answering "disabled"
// faster than "wrong password" would confirm the account exists to someone
// who does not know its password.
if !cred.Enabled {
return loginIdentity{}, false
}
return loginIdentity{
actor: cred.Username,
userID: cred.ID,
epoch: cred.TokenEpoch,
permissions: cred.Permissions,
}, true
}
// hashAdminPassword validates a new password and returns its bcrypt hash.
func hashAdminPassword(password string) (string, error) {
if err := validateAdminPassword(password); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// validateAdminPassword deliberately imposes no length floor and no
// composition rule: the operator picks the password.
//
// The two checks that remain are not policy. A blank password is not a weak
// password, it is no password -- anyone who learns the username is in. And
// bcrypt silently ignores everything past 72 bytes, so a longer one is refused
// rather than quietly truncated to something the operator did not choose and
// cannot reproduce.
func validateAdminPassword(password string) error {
if strings.TrimSpace(password) == "" {
return errPasswordBlank
}
if len([]byte(password)) > 72 {
return errPasswordTooLong
}
return nil
}
var (
errPasswordTooLong = errors.New("password must be at most 72 bytes")
errPasswordBlank = errors.New("password must not be blank")
errUsernameInvalid = errors.New("username must be 3-64 characters: letters, digits, dot, dash or underscore")
)
// validateAdminUsername keeps usernames to a shape that reads the same
// everywhere it is displayed. Anything outside it -- spaces, control
// characters, look-alike unicode -- is refused rather than normalised, since a
// username that renders differently from what is stored is a way to be
// mistaken for another operator.
func validateAdminUsername(username string) error {
if n := len([]rune(username)); n < 3 || n > 64 {
return errUsernameInvalid
}
for _, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', unicode.IsDigit(r):
case r == '.', r == '-', r == '_':
default:
return errUsernameInvalid
}
}
return nil
}

View file

@ -0,0 +1,292 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"telesrv/internal/identity"
)
func TestValidateAdminPassword(t *testing.T) {
cases := []struct {
name string
password string
want error
}{
{"ok", "correct horse battery", nil},
// No length floor: the operator picks the password, however short.
{"a single character", "x", nil},
{"blank", " ", errPasswordBlank},
{"empty", "", errPasswordBlank},
// bcrypt truncates silently past 72 bytes, so anything longer must be
// refused rather than accepted as a password the operator did not set.
{"past bcrypt's input limit", strings.Repeat("a", 73), errPasswordTooLong},
{"73 bytes of multibyte runes", strings.Repeat("é", 37), errPasswordTooLong},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAdminPassword(tc.password)
if !errors.Is(err, tc.want) {
t.Fatalf("validateAdminPassword(%q) = %v, want %v", tc.password, err, tc.want)
}
})
}
}
func TestValidateAdminUsername(t *testing.T) {
valid := []string{"admin", "ops.lead", "on-call_2", strings.Repeat("a", 64)}
for _, username := range valid {
if err := validateAdminUsername(username); err != nil {
t.Errorf("validateAdminUsername(%q) = %v, want nil", username, err)
}
}
invalid := []string{
"",
"ab", // under the floor
strings.Repeat("a", 65), // over the ceiling
"has space",
"with\ttab",
"with\nnewline",
"аdmin", // Cyrillic 'а': renders like "admin" but is a different operator
"admin*", // permission wildcard has no business in a name
"a@b",
}
for _, username := range invalid {
if err := validateAdminUsername(username); !errors.Is(err, errUsernameInvalid) {
t.Errorf("validateAdminUsername(%q) = %v, want errUsernameInvalid", username, err)
}
}
}
func TestHashAdminPasswordRoundTrips(t *testing.T) {
const password = "a sufficiently long password"
hash, err := hashAdminPassword(password)
if err != nil {
t.Fatalf("hashAdminPassword: %v", err)
}
if strings.Contains(hash, password) {
t.Fatal("hash contains the plaintext")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("hash does not verify against its own password: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"x")); err == nil {
t.Fatal("hash verified against the wrong password")
}
if cost, err := bcrypt.Cost([]byte(hash)); err != nil || cost != bcryptCost {
t.Fatalf("cost = %d (err %v), want %d", cost, err, bcryptCost)
}
}
func TestHashAdminPasswordRejectsInvalid(t *testing.T) {
// A short password is fine; an absent one is not.
if _, err := hashAdminPassword("x"); err != nil {
t.Fatalf("a one-character password was refused: %v", err)
}
if _, err := hashAdminPassword(" "); !errors.Is(err, errPasswordBlank) {
t.Fatalf("err = %v, want errPasswordBlank", err)
}
if _, err := hashAdminPassword(strings.Repeat("a", 73)); !errors.Is(err, errPasswordTooLong) {
t.Fatalf("err = %v, want errPasswordTooLong", err)
}
}
// The dummy hash exists so a login against an unknown username costs the same
// bcrypt work as a real one. If it were malformed, CompareHashAndPassword would
// return early and hand back the timing signal it is there to remove.
func TestDummyBcryptHashIsWellFormedAndUnusable(t *testing.T) {
cost, err := bcrypt.Cost([]byte(dummyBcryptHash))
if err != nil {
t.Fatalf("dummy hash is not a valid bcrypt hash: %v", err)
}
if cost != bcryptCost {
t.Fatalf("dummy hash cost = %d, want %d -- it must cost the same as a real one", cost, bcryptCost)
}
for _, guess := range []string{"", "password", "admin", dummyBcryptHash} {
if err := bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(guess)); err == nil {
t.Fatalf("dummy hash authenticated %q", guess)
}
}
}
func TestNormalisePermissions(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"trims and drops empties", []string{" a ", "", " ", "b"}, []string{"a", "b"}},
{"de-duplicates", []string{"a", "a", "b", "a"}, []string{"a", "b"}},
// A stored list that both names the wildcard and lists rights would read
// narrower than it actually is wherever it is displayed.
{"wildcard collapses everything", []string{"a", "*", "b"}, []string{permissionAll}},
{"wildcard alone", []string{"*"}, []string{permissionAll}},
{"empty stays empty", []string{}, []string{}},
{"only blanks", []string{"", " "}, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalisePermissions(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
}
})
}
}
// assignablePermissions drives the account editor. Offering "*" there would let
// a click hand out every right including admins.manage, which is exactly what
// the per-permission list exists to make deliberate.
func TestAssignablePermissionsExcludesWildcard(t *testing.T) {
for _, p := range assignablePermissions() {
if p == permissionAll {
t.Fatal("assignablePermissions offers the wildcard")
}
}
var sawAdminsManage bool
for _, p := range assignablePermissions() {
if p == permissionAdminsManage {
sawAdminsManage = true
}
}
if !sawAdminsManage {
t.Fatal("assignablePermissions omits admins.manage, so it could never be granted")
}
}
// A blank username must never authenticate, even with the correct break-glass
// secret. It briefly did, which made an empty field an unnamed second route to
// the highest-privilege login; the operator has to be asked for by name.
func TestBlankUsernameNeverAuthenticates(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"", " ", "\t"} {
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"}); ok {
t.Fatalf("blank username %q authenticated", username)
}
}
// The same secret under the operator's actual name still works, so the
// check above is refusing the blank name rather than the credential.
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "letmein"})
if !ok {
t.Fatal("the break-glass operator could not sign in by name")
}
if identity.actor != breakGlassUsername {
t.Fatalf("actor = %q, want %q", identity.actor, breakGlassUsername)
}
if identity.userID != 0 {
t.Fatalf("userID = %d, want 0 -- the break-glass operator has no database row", identity.userID)
}
}
// Case is not a way to get a different operator: the name resolves to the
// break-glass login however it is typed, matching the case-insensitive unique
// index that named accounts live under.
func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"owpengram", "OwpenGram", "OWPENGRAM", " owpengram "} {
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"})
if !ok {
t.Fatalf("%q did not resolve to the break-glass operator", username)
}
if identity.actor != breakGlassUsername {
t.Fatalf("%q signed in as %q", username, identity.actor)
}
}
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "wrong"}); ok {
t.Fatal("the break-glass operator authenticated with the wrong secret")
}
}
// The break-glass password quickstart generates for the very first login
// must stop authenticating once the first-run wizard is done, but a
// password an operator actually chose -- even one that happens to still be
// sitting in .env from before the wizard finished -- must never be
// affected by that. This is the actual integration point between
// validSecret and identity.Store; the package's own tests cover
// SetupPending/TemporaryPasswordMatches in isolation.
func TestValidSecretRetiresOnlyTheGeneratedPassword(t *testing.T) {
dir := t.TempDir()
store := identity.NewStore(dir)
s := &server{cfg: uiConfig{Password: "generated-once", Permissions: []string{permissionAll}}, identity: store}
// No marker written at all yet (identity.Store's zero state) -- the
// password behaves like an ordinary one an operator set.
if !s.validSecret("generated-once") {
t.Fatal("password should authenticate before any wizard marker exists")
}
// Bootstrap-style: the setup-pending marker plus the matching
// temporary-password marker, exactly as tui-panel/server-panel.py's
// bootstrap_env() writes them on a fresh install.
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".setup_pending"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".admin_password_temporary"), []byte("generated-once"), 0o644); err != nil {
t.Fatal(err)
}
if !s.validSecret("generated-once") {
t.Fatal("the generated password must keep working while the wizard is still pending")
}
// Wizard finishes: MarkSetupComplete removes both markers.
if err := store.MarkSetupComplete(); err != nil {
t.Fatal(err)
}
if s.validSecret("generated-once") {
t.Fatal("the generated password must stop authenticating once setup is complete")
}
// An operator-chosen password behaves normally regardless: setting a
// new .env value (this test's stand-in for that) authenticates whether
// or not a wizard ever ran, because it never matches either marker.
s.cfg.Password = "an-operator-actually-chose-this"
if !s.validSecret("an-operator-actually-chose-this") {
t.Fatal("an operator-chosen password must authenticate after setup completion, same as always")
}
}
// A session for a named account must not be trusted on the strength of its
// signature alone: the account's rights are re-read per request, and a nil read
// store has to fail closed rather than fall back to the claims.
func TestCurrentSessionPermissionsFailsClosedWithoutStore(t *testing.T) {
s := &server{}
if _, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 7,
Epoch: 1,
Permissions: []string{permissionAll},
}); ok {
t.Fatal("a named-account session was accepted with no store to verify it against")
}
}
// The break-glass operator has no row to re-read, so it keeps the configured
// rights -- that login is the way back in when the database is unreachable.
func TestCurrentSessionPermissionsAllowsBreakGlass(t *testing.T) {
s := &server{}
perms, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 0,
Permissions: []string{permissionServerManage},
})
if !ok {
t.Fatal("break-glass session rejected")
}
if !perms.Has(permissionServerManage) {
t.Fatal("break-glass session lost its configured permission")
}
if perms.Has(permissionAdminsManage) {
t.Fatal("break-glass session gained a permission it was not configured with")
}
}

View file

@ -0,0 +1,125 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// AdminConsoleUser is one named panel operator. It deliberately never carries
// the password hash outside authentication: everything that renders or returns
// a user uses this shape, so a hash cannot leak into an API response by
// someone adding a field to a JSON struct.
type AdminConsoleUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
TokenEpoch int32 `json:"token_epoch"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// adminConsoleCredential is the authentication-only view: the hash plus the
// few fields a login decision needs. Kept unexported and separate from
// AdminConsoleUser so the hash has exactly one reason to be read.
type adminConsoleCredential struct {
ID int64
Username string
PasswordHash string
Permissions []string
Enabled bool
TokenEpoch int32
}
// errAdminUserNotFound is returned instead of pgx.ErrNoRows so callers can
// treat "no such operator" without importing pgx.
var errAdminUserNotFound = errors.New("admin console user not found")
const adminConsoleUserColumns = `id, username, permissions, enabled, token_epoch, created_at, updated_at, last_login_at`
// AdminConsoleCredentialByUsername loads the authentication view for a login
// attempt. The lookup is case-insensitive to match the unique index, so an
// operator cannot be shadowed by a differently-cased duplicate.
func (s *readStore) AdminConsoleCredentialByUsername(ctx context.Context, username string) (adminConsoleCredential, error) {
var out adminConsoleCredential
err := s.pool.QueryRow(ctx, `
SELECT id, username, password_hash, permissions, enabled, token_epoch
FROM admin_console_users
WHERE lower(username) = lower($1)`, strings.TrimSpace(username)).Scan(
&out.ID, &out.Username, &out.PasswordHash, &out.Permissions, &out.Enabled, &out.TokenEpoch)
if errors.Is(err, pgx.ErrNoRows) {
return adminConsoleCredential{}, errAdminUserNotFound
}
if err != nil {
return adminConsoleCredential{}, fmt.Errorf("load admin console credential: %w", err)
}
return out, nil
}
// AdminConsoleSessionState re-reads the two things a live session depends on.
// requireAuthAPI calls it per request so that disabling an operator, editing
// their rights or changing their password takes effect immediately rather than
// whenever their signed cookie happens to expire.
func (s *readStore) AdminConsoleSessionState(ctx context.Context, id int64) (enabled bool, epoch int32, permissions []string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT enabled, token_epoch, permissions FROM admin_console_users WHERE id = $1`, id).
Scan(&enabled, &epoch, &permissions)
if errors.Is(err, pgx.ErrNoRows) {
return false, 0, nil, errAdminUserNotFound
}
if err != nil {
return false, 0, nil, fmt.Errorf("load admin console session state: %w", err)
}
return enabled, epoch, permissions, nil
}
// ListAdminConsoleUsers returns every operator, newest last so the list reads
// like the order they were added.
func (s *readStore) ListAdminConsoleUsers(ctx context.Context) ([]AdminConsoleUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+adminConsoleUserColumns+` FROM admin_console_users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list admin console users: %w", err)
}
defer rows.Close()
out := []AdminConsoleUser{}
for rows.Next() {
var u AdminConsoleUser
if err := rows.Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled,
&u.TokenEpoch, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt); err != nil {
return nil, fmt.Errorf("scan admin console user: %w", err)
}
if u.Permissions == nil {
u.Permissions = []string{}
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate admin console users: %w", err)
}
return out, nil
}
// CountEnabledAdminConsoleUsersWith reports how many enabled operators hold a
// given permission, counting the '*' wildcard as holding everything. It exists
// for the last-administrator guard: the panel refuses the edit that would
// leave nobody able to manage operators.
func (s *readStore) CountEnabledAdminConsoleUsersWith(ctx context.Context, permission string, excludeID int64) (int, error) {
var n int
if err := s.pool.QueryRow(ctx, `
SELECT count(*)::int FROM admin_console_users
WHERE enabled
AND id <> $2
AND (permissions @> ARRAY[$1]::text[] OR permissions @> ARRAY['*']::text[])`,
permission, excludeID).Scan(&n); err != nil {
return 0, fmt.Errorf("count admin console users with permission: %w", err)
}
return n, nil
}

View file

@ -0,0 +1,400 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/admin"
)
// Operator accounts are written here rather than through callAdminAPI like the
// domain mutations are, on purpose. They are not a Telegram entity: they are
// the console's own authentication, and routing them through the domain API
// would mean the console cannot fix its own locked-out operators whenever that
// service is unreachable -- exactly when you need to. Reads already go straight
// to Postgres for the same reason, so this keeps one owner for one table.
//
// They do follow the panel's command convention: every mutation is a
// /api/actions/* route that takes a reason, runs as a dry run first and returns
// an admin.CommandResult. Granting somebody the run of the console deserves the
// same "here is what this will do, confirm it" step as freezing an account.
// requireAdminsManage is the single gate for every operator-account route, so
// none of them can be registered without it by accident.
func (s *server) requireAdminsManage(next http.Handler) http.Handler {
return s.scopedRoute(permissionAdminsManage, next)
}
// errAdminUsernameTaken maps the unique-index violation to something the panel
// can show, without leaking the constraint name.
var errAdminUsernameTaken = errors.New("username is already taken")
// errLastManagerStanding guards against an edit that would leave nobody able to
// administer operators. The break-glass credential could still recover it, but
// that is a recovery path, not a thing to walk into by accident.
var errLastManagerStanding = errors.New("this would leave no enabled account able to manage operators")
// errUsernameReserved guards the break-glass name, which authentication
// resolves before the table is consulted.
var errUsernameReserved = errors.New("this username is reserved for the built-in operator")
// createAdminConsoleUser inserts a new operator. token_epoch starts at 1; there
// are no sessions to invalidate yet.
func (s *server) createAdminConsoleUser(ctx context.Context, username, password string, permissions []string, enabled bool) (AdminConsoleUser, error) {
if err := validateAdminUsername(username); err != nil {
return AdminConsoleUser{}, err
}
// authenticateLogin resolves this name to the environment credential before
// it ever reaches the table, so a row by this name could never be logged
// into. Refuse it rather than storing an account that silently does nothing.
if strings.EqualFold(strings.TrimSpace(username), breakGlassUsername) {
return AdminConsoleUser{}, errUsernameReserved
}
hash, err := hashAdminPassword(password)
if err != nil {
return AdminConsoleUser{}, err
}
permissions = normalisePermissions(permissions)
var u AdminConsoleUser
err = s.read.pool.QueryRow(ctx, `
INSERT INTO admin_console_users (username, password_hash, permissions, enabled)
VALUES ($1, $2, $3, $4)
RETURNING `+adminConsoleUserColumns,
strings.TrimSpace(username), hash, permissions, enabled).
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return AdminConsoleUser{}, errAdminUsernameTaken
}
if err != nil {
return AdminConsoleUser{}, fmt.Errorf("create admin console user: %w", err)
}
if u.Permissions == nil {
u.Permissions = []string{}
}
return u, nil
}
// updateAdminConsoleUser changes permissions and/or enabled state.
//
// It deliberately does NOT move token_epoch. currentSessionPermissions re-reads
// this row on every request, so a narrowed permission set applies from the
// operator's next request and a disabled account is refused outright -- both
// without ending a session. Bumping the epoch here would only sign someone out
// mid-task to achieve what the re-read already achieves.
//
// A password change is different and does bump it: the password is not
// re-checked per request, so nothing else would retire the old sessions.
func (s *server) updateAdminConsoleUser(ctx context.Context, id int64, permissions []string, enabled bool) (AdminConsoleUser, error) {
permissions = normalisePermissions(permissions)
var u AdminConsoleUser
err := s.read.pool.QueryRow(ctx, `
UPDATE admin_console_users
SET permissions = $2,
enabled = $3,
updated_at = now()
WHERE id = $1
RETURNING `+adminConsoleUserColumns,
id, permissions, enabled).
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
if errors.Is(err, pgx.ErrNoRows) {
return AdminConsoleUser{}, errAdminUserNotFound
}
if err != nil {
return AdminConsoleUser{}, fmt.Errorf("update admin console user: %w", err)
}
if u.Permissions == nil {
u.Permissions = []string{}
}
return u, nil
}
// setAdminConsoleUserPassword replaces the hash and bumps the epoch, so a
// password change signs out whoever was using the old one -- which is the
// point of changing it after a suspected compromise.
func (s *server) setAdminConsoleUserPassword(ctx context.Context, id int64, password string) error {
hash, err := hashAdminPassword(password)
if err != nil {
return err
}
tag, err := s.read.pool.Exec(ctx, `
UPDATE admin_console_users
SET password_hash = $2, token_epoch = token_epoch + 1, updated_at = now()
WHERE id = $1`, id, hash)
if err != nil {
return fmt.Errorf("set admin console user password: %w", err)
}
if tag.RowsAffected() == 0 {
return errAdminUserNotFound
}
return nil
}
// normalisePermissions trims, de-duplicates and collapses to the wildcard when
// it is present, so "*" plus a list cannot be stored as something that reads
// narrower than it is.
func normalisePermissions(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, p := range in {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if p == permissionAll {
return []string{permissionAll}
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
// --- HTTP surface -----------------------------------------------------------
// adminUserActionRequest carries the panel's usual command envelope alongside
// the operator fields. ID is absent when creating.
type adminUserActionRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Permissions []string `json:"permissions"`
Enabled *bool `json:"enabled"`
}
func (s *server) handleListAdminUsersAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
users, err := s.read.ListAdminConsoleUsers(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
// The built-in operator has no database row, so it would otherwise be
// invisible here -- a list of who can sign in that omits the account
// with the most rights is worse than no list. It is reported first and
// flagged as system; the panel renders it read-only, and every mutation
// below refuses it anyway.
"system": map[string]any{
"username": breakGlassUsername,
"permissions": newPanelPermissions(s.cfg.Permissions).List(),
"enabled": true,
"system": true,
},
"rows": users,
// The vocabulary the panel offers when editing an account, so the list
// of assignable rights lives in one place instead of being duplicated
// in the frontend and drifting from what the routes actually check.
"available_permissions": assignablePermissions(),
})
}
// handleCreateAdminUserAPI runs as a dry run unless confirmed.
func (s *server) handleCreateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
var body adminUserActionRequest
if !s.decodeAdminUserAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-create")
enabled := body.Enabled == nil || *body.Enabled
permissions := normalisePermissions(body.Permissions)
// Validate on the dry run too, so "this will fail" is discovered before the
// operator is asked to confirm rather than after.
if err := validateAdminUsername(strings.TrimSpace(body.Username)); err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
return
}
if strings.EqualFold(strings.TrimSpace(body.Username), breakGlassUsername) {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, errUsernameReserved)
return
}
if err := validateAdminPassword(body.Password); err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
return
}
if meta.DryRun {
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: "create-admin-operator",
Status: "ok",
DryRun: true,
Message: fmt.Sprintf("Would create operator %q with %d permission(s), %s.",
strings.TrimSpace(body.Username), len(permissions), enabledWord(enabled)),
Details: map[string]any{
"username": strings.TrimSpace(body.Username),
"permissions": permissions,
"enabled": enabled,
},
})
return
}
user, err := s.createAdminConsoleUser(r.Context(), body.Username, body.Password, permissions, enabled)
if err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
return
}
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: "create-admin-operator",
Status: "ok",
Message: fmt.Sprintf("Created operator %q.", user.Username),
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
})
}
// handleUpdateAdminUserAPI changes rights and/or enabled state, dry run first.
func (s *server) handleUpdateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
var body adminUserActionRequest
if !s.decodeAdminUserAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-access")
const action = "set-admin-operator-access"
enabled := body.Enabled == nil || *body.Enabled
permissions := normalisePermissions(body.Permissions)
if body.ID <= 0 {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
return
}
if err := s.guardManagerRemoval(r.Context(), body.ID, permissions, enabled); err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
return
}
if meta.DryRun {
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: action,
Status: "ok",
DryRun: true,
Message: fmt.Sprintf("Would set operator #%d to %d permission(s), %s. Takes effect on their next request.",
body.ID, len(permissions), enabledWord(enabled)),
Details: map[string]any{"id": body.ID, "permissions": permissions, "enabled": enabled},
})
return
}
user, err := s.updateAdminConsoleUser(r.Context(), body.ID, permissions, enabled)
if err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
return
}
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: action,
Status: "ok",
Message: fmt.Sprintf("Updated %q. The new access applies from their next request.", user.Username),
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
})
}
// handleSetAdminUserPasswordAPI resets a password, dry run first.
func (s *server) handleSetAdminUserPasswordAPI(w http.ResponseWriter, r *http.Request) {
var body adminUserActionRequest
if !s.decodeAdminUserAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-password")
const action = "set-admin-operator-password"
if body.ID <= 0 {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
return
}
if err := validateAdminPassword(body.Password); err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
return
}
if meta.DryRun {
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: action,
Status: "ok",
DryRun: true,
Message: fmt.Sprintf("Would set a new password for operator #%d. Their existing sessions would be signed out.", body.ID),
// The password itself is never echoed, not even back to the
// operator who just typed it.
Details: map[string]any{"id": body.ID},
})
return
}
if err := s.setAdminConsoleUserPassword(r.Context(), body.ID, body.Password); err != nil {
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
return
}
writeJSON(w, http.StatusOK, admin.CommandResult{
CommandID: meta.CommandID,
Action: action,
Status: "ok",
Message: fmt.Sprintf("Password changed for operator #%d. Their existing sessions are signed out.", body.ID),
Details: map[string]any{"id": body.ID},
})
}
// decodeAdminUserAction shares the store check, body decode and reason
// requirement across the three mutations.
func (s *server) decodeAdminUserAction(w http.ResponseWriter, r *http.Request, body *adminUserActionRequest) bool {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return false
}
if err := decodeJSON(r, body); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return false
}
if strings.TrimSpace(body.Reason) == "" {
writeAPIError(w, http.StatusBadRequest, "a reason is required")
return false
}
return true
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
// guardManagerRemoval refuses an edit that would leave nobody able to manage
// operators. Counted over the other accounts, so demoting or disabling the
// only remaining manager is what trips it.
func (s *server) guardManagerRemoval(ctx context.Context, id int64, permissions []string, enabled bool) error {
stillManages := enabled && newPanelPermissions(permissions).Has(permissionAdminsManage)
if stillManages {
return nil
}
others, err := s.read.CountEnabledAdminConsoleUsersWith(ctx, permissionAdminsManage, id)
if err != nil {
return err
}
if others == 0 {
return errLastManagerStanding
}
return nil
}

View file

@ -0,0 +1,101 @@
package main
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
// guardManagerRemoval is the only thing between an operator and a console
// nobody can administer, and it decides from a COUNT over admin_console_users
// whose predicate treats '*' as holding every right. Neither that wildcard
// matching nor the enabled filter can be proven anywhere but against the real
// table, so this is an integration test, gated on TELESRV_TEST_POSTGRES_DSN
// like the rest of the package.
//
// The count spans every enabled manager except the row being edited, so unlike
// the other integration tests here this one cannot keep to its own fixtures
// with a unique suffix -- a manager left behind by an earlier run would make
// the "nobody else" cases silently pass for the wrong reason. It empties
// admin_console_users instead; verificationReadStore refuses a DSN whose
// database name does not contain "test", which is what makes that safe, and no
// other test in the repo touches this table.
func TestGuardManagerRemovalIntegration(t *testing.T) {
store, pool := verificationReadStore(t)
srv := &server{read: store}
ctx := context.Background()
resetTable := func() {
t.Helper()
if _, err := pool.Exec(ctx, `TRUNCATE admin_console_users RESTART IDENTITY`); err != nil {
t.Fatalf("truncate admin_console_users: %v", err)
}
}
resetTable()
t.Cleanup(resetTable)
unique := time.Now().UnixNano() & 0x7fffffff
insertOperator := func(permissions []string, enabled bool) int64 {
t.Helper()
unique++
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO admin_console_users (username, password_hash, permissions, enabled)
VALUES ($1, 'not-a-real-hash', $2, $3)
RETURNING id`, "guardop"+strconv.FormatInt(unique, 10), permissions, enabled).Scan(&id); err != nil {
t.Fatalf("insert operator: %v", err)
}
return id
}
t.Run("sole wildcard holder cannot drop the wildcard", func(t *testing.T) {
resetTable()
id := insertOperator([]string{permissionAll}, true)
err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true)
if !errors.Is(err, errLastManagerStanding) {
t.Fatalf("err = %v, want errLastManagerStanding", err)
}
})
t.Run("sole manager cannot disable itself", func(t *testing.T) {
resetTable()
id := insertOperator([]string{permissionAll}, true)
err := srv.guardManagerRemoval(ctx, id, []string{permissionAll}, false)
if !errors.Is(err, errLastManagerStanding) {
t.Fatalf("err = %v, want errLastManagerStanding", err)
}
})
// Narrowing the wildcard down to the managing right itself is the supported
// way out of full access, so it must not trip the guard.
t.Run("sole manager may trade the wildcard for admins.manage", func(t *testing.T) {
resetTable()
id := insertOperator([]string{permissionAll}, true)
if err := srv.guardManagerRemoval(ctx, id, []string{permissionAdminsManage}, true); err != nil {
t.Fatalf("err = %v, want nil", err)
}
})
// The case the SQL's '*' arm exists for: the remaining manager holds the
// wildcard rather than a literal admins.manage, and must still be counted.
t.Run("another enabled wildcard holder counts as a manager", func(t *testing.T) {
resetTable()
id := insertOperator([]string{permissionAll}, true)
insertOperator([]string{permissionAll}, true)
if err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true); err != nil {
t.Fatalf("err = %v, want nil", err)
}
})
t.Run("a disabled second manager does not count", func(t *testing.T) {
resetTable()
id := insertOperator([]string{permissionAll}, true)
insertOperator([]string{permissionAdminsManage}, false)
err := srv.guardManagerRemoval(ctx, id, []string{permissionAccountsRead}, true)
if !errors.Is(err, errLastManagerStanding) {
t.Fatalf("err = %v, want errLastManagerStanding", err)
}
})
}

View file

@ -0,0 +1,45 @@
package main
import "runtime/debug"
// gitCommit/buildTime can be set via -ldflags "-X main.gitCommit=... -X
// main.buildTime=...", mirroring cmd/telesrv/buildinfo.go -- but in
// practice neither procctl's goBuild (used by the admin panel's own
// Restart/Update) nor a plain `go build` sets them, so this normally falls
// back to Go's automatic VCS stamping (debug.ReadBuildInfo's vcs.revision),
// which needs nothing extra to work from a git checkout.
var (
gitCommit = ""
buildTime = ""
)
type buildMetadata struct {
Commit string
Dirty bool
BuildTime string
}
// shortCommit is what the sidebar footer shows next to "Version: O7" -- the
// full hash is one click away in git log, the footer just needs enough to
// tell two builds apart at a glance.
func (m buildMetadata) shortCommit() string {
if len(m.Commit) > 7 {
return m.Commit[:7]
}
return m.Commit
}
func currentBuildMetadata() buildMetadata {
meta := buildMetadata{Commit: gitCommit, BuildTime: buildTime}
if info, ok := debug.ReadBuildInfo(); ok {
settings := map[string]string{}
for _, setting := range info.Settings {
settings[setting.Key] = setting.Value
}
if meta.Commit == "" {
meta.Commit = settings["vcs.revision"]
}
meta.Dirty = settings["vcs.modified"] == "true"
}
return meta
}

View file

@ -7,9 +7,11 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@ -27,6 +29,23 @@ const hostStatsPollInterval = 5 * time.Second
const defaultAdminAPIAddr = "127.0.0.1:2599" 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() { func main() {
if err := run(); err != nil { if err := run(); err != nil {
log.Fatal(err) log.Fatal(err)
@ -47,7 +66,7 @@ func run() error {
} }
defer pool.Close() defer pool.Close()
hs := hoststats.NewPoller(cfg.BlobDir) hs := hoststats.NewPoller(cfg.DiskStatsPath)
go hs.Run(ctx, hostStatsPollInterval) go hs.Run(ctx, hostStatsPollInterval)
srv, err := newServer(cfg, newReadStore(pool), hs) srv, err := newServer(cfg, newReadStore(pool), hs)
@ -80,10 +99,10 @@ type uiConfig struct {
Password string Password string
Token string Token string
SessionKey []byte SessionKey []byte
// BlobDir is the local blob-storage root, reused only to pick which // DiskStatsPath points the dashboard host-disk sampler at the local path
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when // that matters for the selected blob backend: permanent localfs storage or
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint. // the S3 upload spool.
BlobDir string DiskStatsPath string
// Permissions is the right set a panel session is issued with, from // Permissions is the right set a panel session is issued with, from
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard // TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
// entry, so introducing the permission model never locks an operator out of a // entry, so introducing the permission model never locks an operator out of a
@ -95,6 +114,41 @@ type uiConfig struct {
// permissions, and the session/login response tells the frontend to hide // permissions, and the session/login response tells the frontend to hide
// the "Third-party marks" nav entry and its routes. // the "Third-party marks" nav entry and its routes.
HideThirdPartyVerification bool 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
// WelcomeMessagePhoneDefault/WelcomeMessageEmailDefault mirror
// config.WelcomeMessage{Phone,Email}Template -- the env-var-resolved
// fallback text (TELESRV_WELCOME_MESSAGE_*_TEMPLATE, itself defaulting
// to the compiled-in copy) the running owpengram-server process falls
// back to whenever the identity panel override is unset. Surfaced as
// "the effective default" in the Server Settings login-notifications
// panel, assuming both binaries share the same .env.
WelcomeMessagePhoneDefault string
WelcomeMessageEmailDefault string
// LoginCodeMessageDefault mirrors config.LoginCodeMessageTemplate -- the
// env-var-resolved fallback text (TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE,
// itself defaulting to the compiled-in copy) the running owpengram-server
// process falls back to whenever the identity panel override is unset.
// Same "effective default" contract as WelcomeMessage{Phone,Email}Default.
LoginCodeMessageDefault 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
// AdvertiseHost/ServerPort mirror config.AdvertiseIP and the port half
// of config.ListenAddr -- what the "Add Server" sidebar button needs to
// build an owpg://addserver link for this exact server (host+port only,
// see handleAddServerLinkAPI's doc comment for why nothing else belongs
// in that link), assuming both binaries share the same .env (same
// convention as the WelcomeMessage/LoginCode defaults above).
AdvertiseHost string
ServerPort int
} }
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量, // loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
@ -121,6 +175,20 @@ func loadConfig() (uiConfig, error) {
} }
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey)) sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
repoRoot, err := os.Getwd()
if err != nil {
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
}
_, serverPortStr, err := net.SplitHostPort(appCfg.ListenAddr)
if err != nil {
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN %q: %w", appCfg.ListenAddr, err)
}
serverPort, err := strconv.Atoi(serverPortStr)
if err != nil {
return uiConfig{}, fmt.Errorf("parse TELESRV_LISTEN port %q: %w", serverPortStr, err)
}
return uiConfig{ return uiConfig{
Addr: appCfg.AdminUIAddr, Addr: appCfg.AdminUIAddr,
PostgresDSN: appCfg.PostgresDSN, PostgresDSN: appCfg.PostgresDSN,
@ -129,12 +197,26 @@ func loadConfig() (uiConfig, error) {
Password: appCfg.AdminUIPassword, Password: appCfg.AdminUIPassword,
Token: appCfg.AdminUIToken, Token: appCfg.AdminUIToken,
SessionKey: sum[:], SessionKey: sum[:],
DiskStatsPath: dashboardDiskPath(appCfg),
Permissions: appCfg.AdminUIPermissions, Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification, HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
BlobDir: appCfg.BlobDir, IdentityDir: appCfg.IdentityDir,
WelcomeMessagePhoneDefault: appCfg.WelcomeMessagePhoneTemplate,
WelcomeMessageEmailDefault: appCfg.WelcomeMessageEmailTemplate,
LoginCodeMessageDefault: appCfg.LoginCodeMessageTemplate,
RepoRoot: repoRoot,
AdvertiseHost: appCfg.AdvertiseIP,
ServerPort: serverPort,
}, nil }, 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 { func adminAPIURL(addr string) string {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr == "" {

View file

@ -0,0 +1,64 @@
package main
import (
"net/http"
)
// The login screen shows which server it belongs to, so the two fields it needs
// are served without a session.
//
// This discloses nothing new. owpengram-server already publishes the same name
// and icon to every client that asks, over its own open endpoints
// (/owpengram/server-info and /owpengram/server-icon) -- that is how a client
// fills in the "Add Server" form and how the panel's own RefreshServersInfo
// works. Branding is public by design; the point of a server name is to be
// read before you are anybody.
//
// It stays deliberately narrow all the same: name and whether an icon exists,
// and nothing else off identity.Info, which also carries the description and
// the welcome/login-code message templates. Those are operator-facing settings
// and stay behind server.manage.
type publicBrandingResponse struct {
Name string `json:"name"`
HasIcon bool `json:"has_icon"`
}
func (s *server) handlePublicBrandingAPI(w http.ResponseWriter, _ *http.Request) {
out := publicBrandingResponse{}
if s.identity != nil {
if info, err := s.identity.Get(); err == nil {
out.Name = info.Name
}
_, _, ok := s.identity.Icon()
out.HasIcon = ok
}
// A server that has not been named yet answers with an empty name rather
// than an error: the login page falls back to its own branding, and a
// failed request there would only produce a console error for nothing.
writeJSON(w, http.StatusOK, out)
}
// handlePublicIconAPI serves the same bytes as handleServerIconAPI, without the
// session. Same file, same rationale as above.
func (s *server) handlePublicIconAPI(w http.ResponseWriter, _ *http.Request) {
if s.identity == nil {
http.NotFound(w, nil)
return
}
data, ext, ok := s.identity.Icon()
if !ok {
http.Error(w, "no icon configured", http.StatusNotFound)
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)
}

View file

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -386,6 +387,7 @@ LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.ed
LEFT JOIN auth a ON a.user_id = u.id LEFT JOIN auth a ON a.user_id = u.id
LEFT JOIN account_passwords ap ON ap.user_id = u.id LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE NOT u.is_bot WHERE NOT u.is_bot
AND NOT (u.id = ANY($8::bigint[]))
AND ( AND (
u.id = $1 u.id = $1
OR u.phone LIKE $2 OR u.phone LIKE $2
@ -396,7 +398,7 @@ WHERE NOT u.is_bot
) )
AND ($5::bigint = 0 OR (COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($5::double precision) / 1000000.0), $6::bigint)) AND ($5::bigint = 0 OR (COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($5::double precision) / 1000000.0), $6::bigint))
ORDER BY COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC ORDER BY COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1) LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs())
if err != nil { if err != nil {
return nil, false, fmt.Errorf("search accounts: %w", err) return nil, false, fmt.Errorf("search accounts: %w", err)
} }
@ -508,44 +510,77 @@ type DashboardCounts struct {
PendingVerifications int64 PendingVerifications int64
} }
// DashboardCounts gathers every headline number on the admin overview.
//
// The queries are independent, so they run concurrently: this used to be eight
// round trips in series and the page waited for their sum. errgroup cancels the
// rest as soon as one fails, and each goroutine writes to its own field of
// `out`, so no locking is needed.
func (s *readStore) DashboardCounts(ctx context.Context) (DashboardCounts, error) { func (s *readStore) DashboardCounts(ctx context.Context) (DashboardCounts, error) {
var out DashboardCounts var out DashboardCounts
var err error g, gctx := errgroup.WithContext(ctx)
if out.Users, err = s.CountAccounts(ctx); err != nil {
return out, err g.Go(func() error {
} v, err := s.CountAccounts(gctx)
if out.OnlineUsers, err = s.CountOnlineAccounts(ctx); err != nil { out.Users = v
return out, err return err
} })
if err := s.pool.QueryRow(ctx, ` g.Go(func() error {
v, err := s.CountOnlineAccounts(gctx)
out.OnlineUsers = v
return err
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil { SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil {
return out, fmt.Errorf("count bots: %w", err) return fmt.Errorf("count bots: %w", err)
} }
if err := s.pool.QueryRow(ctx, ` return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*) FILTER (WHERE broadcast), count(*) FILTER (WHERE megagroup) SELECT count(*) FILTER (WHERE broadcast), count(*) FILTER (WHERE megagroup)
FROM channels WHERE NOT deleted AND NOT monoforum`).Scan(&out.BroadcastChannels, &out.Supergroups); err != nil { FROM channels WHERE NOT deleted AND NOT monoforum`).Scan(&out.BroadcastChannels, &out.Supergroups); err != nil {
return out, fmt.Errorf("count channels: %w", err) return fmt.Errorf("count channels: %w", err)
} }
if err := s.pool.QueryRow(ctx, ` return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*) FILTER (WHERE set_kind = 'stickers'), count(*) FILTER (WHERE set_kind = 'emoji') SELECT count(*) FILTER (WHERE set_kind = 'stickers'), count(*) FILTER (WHERE set_kind = 'emoji')
FROM sticker_sets WHERE deleted = false`).Scan(&out.StickerSets, &out.EmojiSets); err != nil { FROM sticker_sets WHERE deleted = false`).Scan(&out.StickerSets, &out.EmojiSets); err != nil {
return out, fmt.Errorf("count sticker sets: %w", err) return fmt.Errorf("count sticker sets: %w", err)
} }
// There's no global GIF catalog -- a GIF is just a document a user saved to return nil
// their personal collection (messages.saveGif). This counts distinct })
// documents saved by anyone, the closest thing to "how many GIFs does this g.Go(func() error {
// server know about." // There's no global GIF catalog -- a GIF is just a document a user saved
if err := s.pool.QueryRow(ctx, ` // to their personal collection (messages.saveGif). This counts distinct
// documents saved by anyone, the closest thing to "how many GIFs does
// this server know about."
if err := s.pool.QueryRow(gctx, `
SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil { SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil {
return out, fmt.Errorf("count gifs: %w", err) return fmt.Errorf("count gifs: %w", err)
} }
if err := s.pool.QueryRow(ctx, ` return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil { SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil {
return out, fmt.Errorf("count pending moderation cases: %w", err) return fmt.Errorf("count pending moderation cases: %w", err)
} }
if err := s.pool.QueryRow(ctx, ` return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil { SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil {
return out, fmt.Errorf("count pending verification applications: %w", err) return fmt.Errorf("count pending verification applications: %w", err)
}
return nil
})
if err := g.Wait(); err != nil {
return DashboardCounts{}, err
} }
return out, nil return out, nil
} }
@ -837,19 +872,23 @@ WITH auth AS (
SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at, SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at,
COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint,
auth.last_active_at, auth.device_count, COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int,
COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
COALESCE(ap.login_email, ''), COALESCE(ap.login_email, ''),
`+accountCollectibleUsernamesColumn+` AS collectibles `+accountCollectibleUsernamesColumn+` AS collectibles
FROM users u FROM users u
JOIN auth ON auth.user_id = u.id -- LEFT JOIN, not JOIN: an account with no authorizations (never finished login,
-- all sessions revoked, frozen-then-unfrozen) must still appear here, matching
-- CountAccounts and SearchAccounts.
LEFT JOIN auth ON auth.user_id = u.id
LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN account_restrictions r ON r.user_id = u.id
LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable
LEFT JOIN account_passwords ap ON ap.user_id = u.id LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE NOT u.is_bot WHERE NOT u.is_bot
AND ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) AND NOT (u.id = ANY($4::bigint[]))
ORDER BY auth.last_active_at DESC, u.id DESC AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint))
LIMIT $3`, beforeActiveUS, beforeID, limit+1) ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC
LIMIT $3`, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs())
if err != nil { if err != nil {
return nil, false, fmt.Errorf("list accounts: %w", err) return nil, false, fmt.Errorf("list accounts: %w", err)
} }
@ -954,27 +993,31 @@ ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.
return groups, hasMore, nil return groups, hasMore, nil
} }
// BroadcastRow is one system-broadcast campaign, with sent/failed counts // BroadcastRow is one system-broadcast campaign. SentCount/FailedCount/
// derived live from broadcast_recipients (never stored, so they can't drift). // MaterializedCount are maintained incrementally by the delivery worker as
// it closes out each recipient row (see internal/app/broadcast); for an
// "all"-mode campaign still enumerating, TargetCount grows until
// EnumerationDone.
type BroadcastRow struct { type BroadcastRow struct {
ID int64 ID int64
Message string Message string
TargetMode string TargetMode string
TotalCount int TargetCount int64
SentCount int MaterializedCount int64
FailedCount int SentCount int64
CreatedBy string FailedCount int64
CreatedAt time.Time EnumerationDone bool
CreatedBy string
CreatedAt time.Time
} }
const broadcastRowColumns = ` const broadcastRowColumns = `
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at, b.id, b.message, b.target_mode, b.target_count, b.materialized_count,
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count, b.sent_count, b.failed_count, b.enumeration_done, b.created_by, b.created_at`
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error { func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error {
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt, return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
&item.SentCount, &item.FailedCount) &item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt)
} }
// ListBroadcasts pages campaigns newest-first. // ListBroadcasts pages campaigns newest-first.
@ -985,9 +1028,7 @@ func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit in
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT `+broadcastRowColumns+` SELECT `+broadcastRowColumns+`
FROM broadcasts b FROM broadcasts b
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
WHERE $1::bigint = 0 OR b.id < $1 WHERE $1::bigint = 0 OR b.id < $1
GROUP BY b.id
ORDER BY b.id DESC ORDER BY b.id DESC
LIMIT $2`, beforeID, limit+1) LIMIT $2`, beforeID, limit+1)
if err != nil { if err != nil {
@ -2535,62 +2576,181 @@ const (
) )
// perOwnerMediaSizeSQL is shared between StorageStats and // perOwnerMediaSizeSQL is shared between StorageStats and
// ListAccountStorageUsage: each document row's size is its stored column; // ListAccountStorageUsage: attributes each document/photo's REAL remaining
// each photo row has no single size column (JSONB sizes holds one entry per // bytes (the sum of every file_blobs row it still owns -- main body plus
// rendition), so its "attributed size" is the largest rendition -- the // thumbnail/rendition variants) to its owner, one row per document/photo so
// dominant cost, thumbnails are comparatively tiny. This is an // an outer COUNT(*)/SUM(size) aggregate still gets an accurate file count
// approximation (not the exact sum of every rendition's blob bytes, which // alongside the byte total. This used to read documents.size / the largest
// would require joining file_blobs by location_key prefix) chosen for admin // photo rendition size directly -- a static value on the metadata row that
// visibility, not billing precision. // survives a hard-retention purge unchanged, so it kept counting bytes for
// files whose blobs were long gone. Joining through file_blobs instead means
// a purged item correctly contributes 0: there is nothing left to attribute.
const perOwnerMediaSizeSQL = ` const perOwnerMediaSizeSQL = `
SELECT owner_user_id, size FROM documents SELECT d.owner_user_id, COALESCE((
SELECT SUM(fb.size) FROM file_blobs fb
WHERE fb.location_key = 'doc:' || d.id::text
OR fb.location_key LIKE 'doc:' || d.id::text || ':%'
), 0) AS size
FROM documents d
UNION ALL UNION ALL
SELECT p.owner_user_id, COALESCE(( SELECT p.owner_user_id, COALESCE((
SELECT MAX((elem->>'size')::bigint) FROM jsonb_array_elements(p.sizes) elem SELECT SUM(fb.size) FROM file_blobs fb
WHERE fb.location_key = 'photo:' || p.id::text
OR fb.location_key LIKE 'photo:' || p.id::text || ':%'
), 0) AS size ), 0) AS size
FROM photos p FROM photos p
` `
// StorageStatsRow is the admin panel's storage overview: physical bytes // StorageStatsRow is the admin panel's storage overview: physical bytes
// (from file_blobs, backend-dedup-aware -- what's actually consuming disk // (from file_blobs, backend-dedup-aware -- what's actually consuming disk
// or S3) versus logical bytes (sum of the same approximate per-row // or S3, deduplicated exactly once across the whole system) versus logical
// attribution the per-account breakdown uses, which can legitimately be // bytes (the SAME real, still-existing file_blobs bytes as physical, just
// higher than physical when identical content is shared by more than one // summed per-owner via perOwnerMediaSizeSQL without deduplicating content
// document/photo). // shared across accounts/documents -- so logical can legitimately be higher
// than physical when the same blob is attributed to more than one
// document/photo, but a purged file with no file_blobs rows left correctly
// contributes 0 to both, never a stale non-zero "ghost" size).
//
// Every field here deliberately EXCLUDES system/bundled content (owner_user_id
// = 0 on the documents/photos row -- the built-in sticker packs, emoji sets,
// default wallpapers, GIF catalog, and system-bot avatars this server seeds
// at every boot; see internal/app/files's Seed* functions). None of that is
// something an operator manages through storage retention/purge -- it isn't
// one of the Photo/Video/GIF/Music/Voice/File/Avatar categories those
// controls target, it's permanent server furniture -- so counting it here
// alongside real user uploads made every number on this page mean "user
// storage plus an unpredictable pile of bundled assets" instead of just
// answering "how much space are my users actually using". SystemBytes below
// is the one exception: it reports that excluded total separately, purely
// for an operator's own curiosity/disk-accounting, never folded into the
// other totals.
type StorageStatsRow struct { type StorageStatsRow struct {
PhysicalBytes int64 `json:"PhysicalBytes,string"` PhysicalBytes int64 `json:"PhysicalBytes,string"`
LogicalBytes int64 `json:"LogicalBytes,string"` LogicalBytes int64 `json:"LogicalBytes,string"`
UnattributedBytes int64 `json:"UnattributedBytes,string"` // SystemBytes is the physical size of excluded system/bundled content
DocumentCount int64 `json:"DocumentCount,string"` // (owner_user_id = 0) -- shown separately so the gap between this and
PhotoCount int64 `json:"PhotoCount,string"` // what `docker exec ... mc du` or the MinIO console reports isn't a
AccountCount int64 `json:"AccountCount,string"` // mystery, but never added into PhysicalBytes/LogicalBytes/DocumentCount/
BackendKind string // PhotoCount/AccountCount above.
SystemBytes int64 `json:"SystemBytes,string"`
// DocumentCount, PhotoCount and AccountCount all count only items that
// still own real file_blobs bytes -- documents/photos rows are
// deliberately kept forever after a hard-retention purge (so a message
// can still render "here was a file"), so counting rows instead of live
// bytes would keep growing even as the actual content becomes physically
// empty, diverging further and further from PhysicalBytes above.
DocumentCount int64 `json:"DocumentCount,string"`
PhotoCount int64 `json:"PhotoCount,string"`
AccountCount int64 `json:"AccountCount,string"`
BackendKind string
} }
// StorageStats returns the admin panel's storage overview. // StorageStats returns the admin panel's storage overview.
// StorageStats runs six aggregates over file_blobs/documents/photos. They are
// independent and each is expensive, so they run concurrently rather than
// summing their latencies -- see file_blobs_location_key_pattern_idx for why
// they were slow in the first place.
func (s *readStore) StorageStats(ctx context.Context) (StorageStatsRow, error) { func (s *readStore) StorageStats(ctx context.Context) (StorageStatsRow, error) {
var stats StorageStatsRow var stats StorageStatsRow
if err := s.pool.QueryRow(ctx, `SELECT COALESCE(SUM(size), 0)::bigint FROM file_blobs`).Scan(&stats.PhysicalBytes); err != nil { g, gctx := errgroup.WithContext(ctx)
return StorageStatsRow{}, fmt.Errorf("sum physical blob bytes: %w", err)
} // Physical usage dedups by (backend, object_key) like the unfiltered
if err := s.pool.QueryRow(ctx, ` // version used to, but only counts an object if at least one real user's
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x`).Scan(&stats.LogicalBytes); err != nil { // (owner_user_id <> 0) document/photo still references it -- content
return StorageStatsRow{}, fmt.Errorf("sum logical media bytes: %w", err) // shared between a system asset and a real upload (content-addressed
} // storage, so only possible via a byte-for-byte coincidental duplicate)
if err := s.pool.QueryRow(ctx, ` // still counts, since a real user genuinely has that data stored.
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id = 0`).Scan(&stats.UnattributedBytes); err != nil { g.Go(func() error {
return StorageStatsRow{}, fmt.Errorf("sum unattributed media bytes: %w", err) // Reads the owner out of the location_key ("doc:<id>", "doc:<id>:<thumb>",
} // "photo:<id>:<size>") and looks it up by primary key, instead of asking
if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM documents`).Scan(&stats.DocumentCount); err != nil { // "is there any document whose id, glued into a string, equals this key".
return StorageStatsRow{}, fmt.Errorf("count documents: %w", err) //
} // That original phrasing was the one query file_blobs_location_key_pattern_idx
if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM photos`).Scan(&stats.PhotoCount); err != nil { // could not help: the pattern is built from d.id and matched against
return StorageStatsRow{}, fmt.Errorf("count photos: %w", err) // fb.location_key, so no index on location_key applies and every blob had
} // to scan documents (then photos) end to end. This direction is a plain
if err := s.pool.QueryRow(ctx, ` // PK probe per blob.
SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0`).Scan(&stats.AccountCount); err != nil { //
return StorageStatsRow{}, fmt.Errorf("count storage accounts: %w", err) // The CASE keeps the cast total -- a key whose second field isn't numeric
// yields NULL rather than raising, and NULL matches no id, exactly like
// the string form matched no row. Keys of other kinds ("enc:<id>") are
// excluded by the kind check, as before.
if err := s.pool.QueryRow(gctx, `
SELECT COALESCE(SUM(size), 0)::bigint FROM (
SELECT DISTINCT ON (fb.backend, fb.object_key) fb.backend, fb.object_key, fb.size
FROM file_blobs fb
CROSS JOIN LATERAL (
SELECT split_part(fb.location_key, ':', 1) AS kind,
CASE WHEN split_part(fb.location_key, ':', 2) ~ '^[0-9]+$'
THEN split_part(fb.location_key, ':', 2)::bigint
END AS owner_id
) k
WHERE (k.kind = 'doc' AND EXISTS (
SELECT 1 FROM documents d WHERE d.id = k.owner_id AND d.owner_user_id <> 0
)) OR (k.kind = 'photo' AND EXISTS (
SELECT 1 FROM photos p WHERE p.id = k.owner_id AND p.owner_user_id <> 0
))
) x`).Scan(&stats.PhysicalBytes); err != nil {
return fmt.Errorf("sum physical blob bytes: %w", err)
}
return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0`).Scan(&stats.LogicalBytes); err != nil {
return fmt.Errorf("sum logical media bytes: %w", err)
}
return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id = 0`).Scan(&stats.SystemBytes); err != nil {
return fmt.Errorf("sum system media bytes: %w", err)
}
return nil
})
// Documents/Photos/AccountCount all count only items that still own real
// file_blobs bytes -- documents/photos rows are deliberately kept forever
// after a hard-retention purge (so a message can still render "here was
// a file"), so a plain count(*) would keep growing even as everything it
// counts becomes physically empty, wildly diverging from PhysicalBytes
// above and making the overview page look broken/confusing rather than
// informative. owner_user_id <> 0 excludes system/bundled content -- see
// StorageStatsRow's doc comment.
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*)::bigint FROM documents d WHERE d.owner_user_id <> 0 AND EXISTS (
SELECT 1 FROM file_blobs fb
WHERE fb.location_key = 'doc:' || d.id::text
OR fb.location_key LIKE 'doc:' || d.id::text || ':%'
)`).Scan(&stats.DocumentCount); err != nil {
return fmt.Errorf("count documents: %w", err)
}
return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(*)::bigint FROM photos p WHERE p.owner_user_id <> 0 AND EXISTS (
SELECT 1 FROM file_blobs fb
WHERE fb.location_key = 'photo:' || p.id::text
OR fb.location_key LIKE 'photo:' || p.id::text || ':%'
)`).Scan(&stats.PhotoCount); err != nil {
return fmt.Errorf("count photos: %w", err)
}
return nil
})
g.Go(func() error {
if err := s.pool.QueryRow(gctx, `
SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0 AND size > 0`).Scan(&stats.AccountCount); err != nil {
return fmt.Errorf("count storage accounts: %w", err)
}
return nil
})
if err := g.Wait(); err != nil {
return StorageStatsRow{}, err
} }
stats.BackendKind = strings.ToLower(strings.TrimSpace(os.Getenv("TELESRV_BLOB_BACKEND"))) stats.BackendKind = strings.ToLower(strings.TrimSpace(os.Getenv("TELESRV_BLOB_BACKEND")))
if stats.BackendKind == "" { if stats.BackendKind == "" {
stats.BackendKind = "s3" stats.BackendKind = "s3"
@ -2608,11 +2768,25 @@ type AccountStorageRow struct {
FileCount int64 `json:"FileCount,string"` FileCount int64 `json:"FileCount,string"`
} }
// ListAccountStorageUsage pages the per-account storage breakdown, largest // storageUsageSortColumns whitelists the columns ListAccountStorageUsage's
// user first. Offset-based (not keyset): storage administration on a // sortBy may map to -- never interpolate the caller's sort key directly into
// self-hosted deployment doesn't need to support arbitrarily deep pages // SQL, since unlike a plain value it can't go through a query parameter.
// efficiently the way an infinite-scroll feed does. var storageUsageSortColumns = map[string]string{
func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit int) ([]AccountStorageRow, bool, error) { "bytes": "t.bytes",
"files": "t.file_count",
"user_id": "t.owner_user_id",
"username": "lower(COALESCE(u.username, ''))",
"first_name": "lower(COALESCE(u.first_name, ''))",
}
// ListAccountStorageUsage pages the per-account storage breakdown. sortBy
// selects one of storageUsageSortColumns (falls back to "bytes" for an
// unknown/empty key); sortDesc reverses it. q, if non-empty, filters to
// accounts whose id/username/first name match it. Offset-based (not
// keyset): storage administration on a self-hosted deployment doesn't need
// to support arbitrarily deep pages efficiently the way an infinite-scroll
// feed does.
func (s *readStore) ListAccountStorageUsage(ctx context.Context, q string, sortBy string, sortDesc bool, offset, limit int) ([]AccountStorageRow, bool, error) {
if limit <= 0 { if limit <= 0 {
limit = storageUsageListDefaultLimit limit = storageUsageListDefaultLimit
} }
@ -2622,19 +2796,51 @@ func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit i
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
column, ok := storageUsageSortColumns[sortBy]
if !ok {
column = storageUsageSortColumns["bytes"]
}
direction := "ASC"
if sortDesc {
direction = "DESC"
}
q = strings.TrimSpace(q)
var whereClause string
args := []any{}
argN := 1
if q != "" {
id := int64(-1)
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
id = n
}
whereClause = fmt.Sprintf(`WHERE t.owner_user_id = $%d
OR lower(COALESCE(u.username, '')) LIKE $%d
OR lower(COALESCE(u.first_name, '')) LIKE $%d`, argN, argN+1, argN+2)
args = append(args, id, "%"+strings.ToLower(q)+"%", "%"+strings.ToLower(q)+"%")
argN += 3
}
offsetArg := argN
limitArg := argN + 1
args = append(args, offset, limit+1)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
WITH totals AS ( WITH totals AS (
-- size > 0 excludes documents/photos whose file_blobs bytes have already
-- been purged -- their row is kept forever (see perOwnerMediaSizeSQL's
-- doc comment) so counting every row here would keep FileCount growing
-- long after Bytes has settled at (or near) 0, same mismatch this query
-- used to have before it was joined through file_blobs at all.
SELECT owner_user_id, SUM(size)::bigint AS bytes, COUNT(*)::bigint AS file_count SELECT owner_user_id, SUM(size)::bigint AS bytes, COUNT(*)::bigint AS file_count
FROM (`+perOwnerMediaSizeSQL+`) x FROM (`+perOwnerMediaSizeSQL+`) x
WHERE owner_user_id <> 0 WHERE owner_user_id <> 0 AND size > 0
GROUP BY owner_user_id GROUP BY owner_user_id
) )
SELECT t.owner_user_id, COALESCE(u.username, ''), COALESCE(u.first_name, ''), t.bytes, t.file_count SELECT t.owner_user_id, COALESCE(u.username, ''), COALESCE(u.first_name, ''), t.bytes, t.file_count
FROM totals t FROM totals t
LEFT JOIN users u ON u.id = t.owner_user_id LEFT JOIN users u ON u.id = t.owner_user_id
ORDER BY t.bytes DESC, t.owner_user_id `+whereClause+`
OFFSET $1 ORDER BY `+column+` `+direction+`, t.owner_user_id
LIMIT $2`, offset, limit+1) OFFSET $`+strconv.Itoa(offsetArg)+`
LIMIT $`+strconv.Itoa(limitArg), args...)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("list account storage usage: %w", err) return nil, false, fmt.Errorf("list account storage usage: %w", err)
} }

View file

@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
userID, userID, "+1889"+suffix, editable); err != nil { userID, userID, "+1889"+suffix, editable); err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
} }
// The list query joins authorizations, so an account with no device never // Give this account a device so its device_count / last_active columns are
// appears there at all; an authorization in turn needs its auth key to exist. // exercised; an authorization needs its auth key to exist first.
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil { INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
t.Fatalf("seed auth key: %v", err) t.Fatalf("seed auth key: %v", err)
@ -123,6 +123,44 @@ WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID);
} }
} }
// An account with no authorizations (never finished login, all sessions revoked,
// frozen-then-unfrozen) must still show up in the Accounts tab - it did not,
// because ListAccounts inner-joined the authorizations aggregate.
func TestReadStoreListAccountsIncludesAccountsWithoutSessions(t *testing.T) {
store, pool := verificationReadStore(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
userID := 3_700_000_000 + time.Now().UnixNano()%1_000_000
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
})
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
VALUES ($1, $2, $3, 'Sessionless', '', '', now(), now())`,
userID, userID, "+42777"+suffix); err != nil {
t.Fatalf("seed user: %v", err)
}
// Deliberately no auth_keys / authorizations rows.
rows, _, err := store.ListAccounts(ctx, 0, 0, 500)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
found := false
for i := range rows {
if rows[i].ID == userID {
found = true
if rows[i].DeviceCount != 0 {
t.Fatalf("device count = %d, want 0 for a sessionless account", rows[i].DeviceCount)
}
}
}
if !found {
t.Fatalf("sessionless account %d absent from ListAccounts (%d rows)", userID, len(rows))
}
}
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) { func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
t.Helper() t.Helper()
if row.Username != editable { if row.Username != editable {

View file

@ -0,0 +1,134 @@
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The panel is deny-by-default: an API route must say which right it belongs
// to. Registering one with a bare requireAuthAPI would make it answer to every
// signed-in operator regardless of what they were granted -- which is how a
// scoped account quietly gets the run of the place.
//
// This reads the source rather than the routing table because that is where the
// mistake is made: it fails on the line someone is about to add, and names it.
func TestEveryAPIRouteDeclaresAScope(t *testing.T) {
// Every /api route must be registered through a wrapper that names a
// permission. Whitelisting the wrappers rather than blacklisting the bare
// one is what makes this hold for helpers added later: a new wrapper is
// unknown here until someone adds it deliberately, so it fails closed.
allowed := []string{
"s.scopedRoute(",
"s.scopedRouteAll(",
"s.requirePermission(",
"s.requireAdminsManage(",
"s.serverManage(",
"s.verificationRead(",
"s.botVerificationRead(",
"s.botVerificationManage(",
}
// Routes that are reachable before a session exists, each for a stated
// reason: /api/login is the way in (it carries its own credential), and the
// two branding routes feed the login screen with the server name and icon
// that owpengram-server already publishes to every client.
exempt := map[string]bool{
"POST /api/login": true,
"GET /api/public/branding": true,
"GET /api/public/icon": true,
}
route := regexp.MustCompile(`mux\.Handle(Func)?\("([A-Z]+ /api/[^"]*)"`)
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package directory: %v", err)
}
var offenders []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
source, err := os.ReadFile(filepath.Clean(name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range strings.Split(string(source), "\n") {
m := route.FindStringSubmatch(line)
if m == nil || exempt[m[2]] {
continue
}
guarded := false
for _, wrapper := range allowed {
if strings.Contains(line, wrapper) {
guarded = true
break
}
}
if !guarded {
offenders = append(offenders, name+": "+strings.TrimSpace(line))
}
}
}
if len(offenders) > 0 {
t.Fatalf("these routes are registered without a permission -- wrap them in s.scopedRoute(permission, ...):\n %s",
strings.Join(offenders, "\n "))
}
}
// Every right the account editor offers must be one the routes actually check,
// and vice versa -- a name in one list and not the other is either a right
// nobody can be granted or a checkbox that grants nothing.
func TestAssignablePermissionsMatchWhatRoutesEnforce(t *testing.T) {
assignable := make(map[string]bool, len(assignablePermissions()))
for _, p := range assignablePermissions() {
if p == permissionSessionOnly {
t.Fatal("permissionSessionOnly is not a grantable right and must not be offered")
}
if assignable[p] {
t.Fatalf("permission %q is offered twice", p)
}
assignable[p] = true
}
if len(assignable) == 0 {
t.Fatal("no assignable permissions")
}
// Spot-check the pairs the sections are built around, so a rename that
// misses one half is caught here rather than by an operator who suddenly
// cannot open a page.
for _, required := range []string{
permissionAccountsRead, permissionAccountsManage,
permissionChannelsRead, permissionChannelsManage,
permissionBotsRead, permissionBotsManage,
permissionMessagesRead, permissionMessagesManage,
permissionContentRead, permissionContentManage,
permissionUsernamesRead, permissionUsernamesManage,
permissionStorageRead, permissionStorageManage,
permissionBroadcastsRead, permissionBroadcastsSend,
permissionModerationReview, permissionDashboardRead,
permissionAdminsManage, permissionServerManage,
} {
if !assignable[required] {
t.Errorf("permission %q is enforced somewhere but cannot be granted", required)
}
}
}
// permissionSessionOnly must stay the empty string: scopedRoute distinguishes
// "a session is enough" from a real right by that emptiness, and panelPermissions
// drops empty entries, so it can never be smuggled into an account's list.
func TestSessionOnlyIsNotGrantable(t *testing.T) {
if permissionSessionOnly != "" {
t.Fatalf("permissionSessionOnly = %q, want the empty string", permissionSessionOnly)
}
perms := newPanelPermissions([]string{permissionSessionOnly, permissionAccountsRead})
if perms.Has(permissionSessionOnly) {
t.Fatal("an empty permission was treated as granted")
}
if !perms.Has(permissionAccountsRead) {
t.Fatal("a real permission alongside it was lost")
}
}

View file

@ -36,6 +36,8 @@ import (
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces. // TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
const ( const (
permissionAll = "*" permissionAll = "*"
permissionPremiumManage = "premium.manage"
permissionBotTokenRead = "bots.token.read"
permissionVerificationReview = "verification.review" permissionVerificationReview = "verification.review"
permissionVerificationRevoke = "verification.revoke" permissionVerificationRevoke = "verification.revoke"
// Third-party bot verification. Deliberately not implied by the official // Third-party bot verification. Deliberately not implied by the official
@ -45,8 +47,122 @@ const (
// curates the icon catalogue and strips granted marks. // curates the icon catalogue and strips granted marks.
permissionBotVerificationReview = "botverification.review" permissionBotVerificationReview = "botverification.review"
permissionBotVerificationManage = "botverification.manage" 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"
// permissionAdminsManage gates the operator accounts themselves: creating
// them, editing their rights, disabling them, resetting their passwords.
//
// It is the one right that can grant every other right, so it is never
// implied by anything else and is worth handing out to far fewer people
// than server.manage. guardManagerRemoval additionally refuses the edit
// that would leave nobody holding it.
permissionAdminsManage = "admins.manage"
// Section rights, in read/manage pairs that follow the sidebar. Reading a
// section and changing it are separate grants because most of the people
// who need to look at this data never need to alter it.
permissionAccountsRead = "accounts.read"
permissionAccountsManage = "accounts.manage"
permissionChannelsRead = "channels.read"
permissionChannelsManage = "channels.manage"
permissionBotsRead = "bots.read"
permissionBotsManage = "bots.manage"
permissionMessagesRead = "messages.read"
permissionMessagesManage = "messages.manage"
permissionModerationReview = "moderation.review"
permissionBroadcastsRead = "broadcasts.read"
permissionBroadcastsSend = "broadcasts.send"
permissionStorageRead = "storage.read"
permissionStorageManage = "storage.manage"
// Sticker packs, emoji packs and the GIF catalogue: one section as far as
// the panel is concerned, so one pair of rights.
permissionContentRead = "content.read"
permissionContentManage = "content.manage"
permissionUsernamesRead = "usernames.read"
permissionUsernamesManage = "usernames.manage"
permissionDashboardRead = "dashboard.read"
// permissionSessionOnly marks the handful of routes that need a session but
// no right: reading who you are, and signing out. It is not a grantable
// name -- scopedRoute treats it as "authenticated is enough" -- so it can
// never be typed into an account's permission list by mistake.
permissionSessionOnly = ""
) )
// assignablePermissions is the vocabulary the operator-accounts screen offers.
//
// The wildcard is deliberately absent: it is meaningful in
// TELESRV_ADMIN_UI_PERMISSIONS for the break-glass login, but handing "*" to a
// named account through a UI is how least privilege quietly stops being a
// thing. An operator who genuinely needs everything gets every entry ticked,
// which at least leaves a legible record of what was granted.
func assignablePermissions() []string {
return []string{
permissionAccountsRead,
permissionAccountsManage,
permissionChannelsRead,
permissionChannelsManage,
permissionBotsRead,
permissionBotsManage,
permissionMessagesRead,
permissionMessagesManage,
permissionModerationReview,
permissionBroadcastsRead,
permissionBroadcastsSend,
permissionContentRead,
permissionContentManage,
permissionUsernamesRead,
permissionUsernamesManage,
permissionStorageRead,
permissionStorageManage,
permissionDashboardRead,
permissionPremiumManage,
permissionBotTokenRead,
permissionVerificationReview,
permissionVerificationRevoke,
permissionBotVerificationReview,
permissionBotVerificationManage,
permissionServerManage,
permissionAdminsManage,
}
}
// scopedRoute is the only way an API route should be registered. Requiring the
// permission as an argument is what makes the panel deny-by-default: a route
// cannot be added without someone stating which right it belongs to, so the
// failure mode of forgetting is a compile error rather than an endpoint that
// quietly answers to everyone.
//
// permissionSessionOnly is the deliberate exception, spelled out at each use.
func (s *server) scopedRoute(permission string, handler http.Handler) http.Handler {
if permission == permissionSessionOnly {
return s.requireAuthAPI(handler)
}
return s.requireAuthAPI(s.requirePermission(permission, handler))
}
// scopedRouteAll is scopedRoute for a route that needs more than one right at
// once -- taking a granted verification badge away needs both the right to work
// the queue and the separate right to revoke. Every permission must be held;
// they are requirements, not alternatives.
func (s *server) scopedRouteAll(permissions []string, handler http.Handler) http.Handler {
if len(permissions) == 0 {
// Refusing outright beats silently degrading to "any session": an empty
// list here is a mistake at the call site, not a way to open a route.
panic("scopedRouteAll: no permissions given")
}
wrapped := handler
for i := len(permissions) - 1; i >= 0; i-- {
wrapped = s.requirePermission(permissions[i], wrapped)
}
return s.requireAuthAPI(wrapped)
}
type permissionsKey struct{} type permissionsKey struct{}
// requireAuthAPI is the gate on every authenticated API route: a valid session, // requireAuthAPI is the gate on every authenticated API route: a valid session,
@ -67,12 +183,46 @@ func (s *server) requireAuthAPI(next http.Handler) http.Handler {
if !checkMutationSafety(w, r, claims) { if !checkMutationSafety(w, r, claims) {
return return
} }
// Rights inside the cookie are a 12-hour snapshot; the account they
// belong to may have been disabled, demoted or had its password changed
// since. Re-read it and use what the database says now, so revocation
// takes effect on the next request rather than at session expiry.
permissions, ok := s.currentSessionPermissions(r.Context(), claims)
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "session is no longer valid")
return
}
ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor) ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor)
ctx = context.WithValue(ctx, permissionsKey{}, newPanelPermissions(claims.Permissions)) ctx = context.WithValue(ctx, permissionsKey{}, permissions)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
// currentSessionPermissions resolves the rights this request actually gets.
//
// The break-glass operator (UserID 0) has no database row and keeps the
// configured set -- that login exists precisely for when the database cannot
// be consulted, so it must not depend on one.
//
// A named account is re-read every request. Anything that moved its token
// epoch invalidates the session; anything that narrowed its permissions
// narrows this request. A read failure is treated as a refusal rather than as
// permission, so a database outage cannot silently widen access.
func (s *server) currentSessionPermissions(ctx context.Context, claims sessionClaims) (panelPermissions, bool) {
if claims.UserID == 0 {
return newPanelPermissions(claims.Permissions), true
}
if s.read == nil {
return panelPermissions{}, false
}
enabled, epoch, permissions, err := s.read.AdminConsoleSessionState(ctx, claims.UserID)
if err != nil || !enabled || epoch != claims.Epoch {
return panelPermissions{}, false
}
return newPanelPermissions(permissions), true
}
// requirePermission refuses a session that was not granted the right, before the // requirePermission refuses a session that was not granted the right, before the
// request ever reaches the admin API. The panel is the only caller that can be // request ever reaches the admin API. The panel is the only caller that can be
// driven by a browser, so the check belongs here as well as upstream: a 403 from // driven by a browser, so the check belongs here as well as upstream: a 403 from

View file

@ -12,14 +12,21 @@ import (
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"path" "path"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"golang.org/x/sync/errgroup"
"telesrv/internal/admin" "telesrv/internal/admin"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/hoststats" "telesrv/internal/hoststats"
"telesrv/internal/identity"
"telesrv/internal/procctl"
) )
//go:embed web/dist //go:embed web/dist
@ -31,6 +38,8 @@ type server struct {
hostStats *hoststats.Poller hostStats *hoststats.Poller
web fs.FS web fs.FS
webServer http.Handler webServer http.Handler
identity *identity.Store
serverCtl *procctl.Manager
} }
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) { func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
@ -44,6 +53,8 @@ func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*ser
hostStats: hostStats, hostStats: hostStats,
web: web, web: web,
webServer: http.FileServer(http.FS(web)), webServer: http.FileServer(http.FS(web)),
identity: identity.NewStore(cfg.IdentityDir),
serverCtl: procctl.NewManager(cfg.RepoRoot),
}, nil }, nil
} }
@ -53,85 +64,106 @@ func (s *server) routes() http.Handler {
// Logout goes through the same gate as every other mutating route: a forced // Logout goes through the same gate as every other mutating route: a forced
// logout is a state change, and an invalid session is cleared by the gate // logout is a state change, and an invalid session is cleared by the gate
// itself, so nothing is stranded by protecting it. // itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout))) mux.Handle("POST /api/logout", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession))) // Unauthenticated on purpose: the login screen shows which server it is,
mux.Handle("GET /api/dashboard", s.requireAuthAPI(http.HandlerFunc(s.handleDashboardAPI))) // and the name/icon are already public from owpengram-server's own client
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI))) // endpoints. See publicbranding.go.
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI))) mux.HandleFunc("GET /api/public/branding", s.handlePublicBrandingAPI)
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI))) mux.HandleFunc("GET /api/public/icon", s.handlePublicIconAPI)
mux.Handle("GET /api/broadcasts", s.requireAuthAPI(http.HandlerFunc(s.handleBroadcastsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI))) mux.Handle("GET /api/session", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI))) // Operator accounts. Every one of these is gated on admins.manage -- the
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI))) // right that can hand out every other right -- so they are registered
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI))) // together rather than scattered among the domain routes.
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI))) mux.Handle("GET /api/admin-users", s.requireAdminsManage(http.HandlerFunc(s.handleListAdminUsersAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI))) // Mutations live under /api/actions/* like every other command in the
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI))) // panel, so they get the same reason + dry-run + confirm flow.
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI))) mux.Handle("POST /api/actions/create-admin-operator", s.requireAdminsManage(http.HandlerFunc(s.handleCreateAdminUserAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI))) mux.Handle("POST /api/actions/set-admin-operator-access", s.requireAdminsManage(http.HandlerFunc(s.handleUpdateAdminUserAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI))) mux.Handle("POST /api/actions/set-admin-operator-password", s.requireAdminsManage(http.HandlerFunc(s.handleSetAdminUserPasswordAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI))) mux.Handle("GET /api/dashboard", s.scopedRoute(permissionDashboardRead, http.HandlerFunc(s.handleDashboardAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI))) mux.Handle("GET /api/accounts", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI))) mux.Handle("GET /api/accounts/stats", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI))) mux.Handle("GET /api/accounts/shared-devices", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI))) mux.Handle("GET /api/broadcasts", s.scopedRoute(permissionBroadcastsRead, http.HandlerFunc(s.handleBroadcastsAPI)))
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI))) mux.Handle("GET /api/accounts/{id}", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI))) mux.Handle("GET /api/accounts/{id}/avatar", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI))) mux.Handle("GET /api/channels", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI))) mux.Handle("GET /api/channels/{id}", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI))) mux.Handle("GET /api/channels/{id}/avatar", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelAvatarAPI)))
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI))) mux.Handle("GET /api/bots", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI))) mux.Handle("GET /api/bots/{id}", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI))) mux.Handle("GET /api/emoji", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI))) mux.Handle("GET /api/emoji/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI))) mux.Handle("GET /api/messages", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI))) mux.Handle("GET /api/messages/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI))) mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI))) mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI))) mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI))) mux.Handle("GET /api/reserved-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleReservedUsernamesAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI))) mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI))) mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI))) mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI))) mux.Handle("GET /api/moderation/cases", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCasesAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI))) mux.Handle("GET /api/moderation/cases/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCaseAPI)))
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI))) mux.Handle("GET /api/moderation/reports/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationReportAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI))) mux.Handle("POST /api/moderation/cases/{id}/claim", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleClaimModerationCaseAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI))) mux.Handle("POST /api/moderation/cases/{id}/decide", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleDecideModerationCaseAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI))) mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleReviewModerationAppealAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI))) mux.Handle("POST /api/actions/set-frozen", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI))) mux.Handle("POST /api/actions/grant-premium", s.scopedRoute(permissionPremiumManage, http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI))) mux.Handle("POST /api/actions/set-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI))) mux.Handle("POST /api/actions/set-account-flags", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI))) mux.Handle("POST /api/actions/set-channel-flags", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI))) mux.Handle("POST /api/actions/set-support", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI))) mux.Handle("POST /api/actions/set-account-username", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI))) mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI))) mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI))) mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI))) mux.Handle("POST /api/actions/set-account-avatar-video", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarVideoAPI)))
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI))) mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI))) mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI))) mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI))) mux.Handle("POST /api/actions/set-channel-avatar", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelAvatarAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI))) mux.Handle("POST /api/actions/set-channel-settings", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI))) mux.Handle("POST /api/actions/set-channel-username", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI))) mux.Handle("POST /api/actions/set-channel-color", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI))) mux.Handle("POST /api/actions/set-channel-emoji-status", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI))) mux.Handle("POST /api/actions/create-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("GET /api/gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogAPI))) mux.Handle("POST /api/actions/create-broadcast", s.scopedRoute(permissionBroadcastsSend, http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI))) mux.Handle("POST /api/actions/delete-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI))) mux.Handle("POST /api/actions/export-bot-token", s.scopedRoute(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI))) mux.Handle("POST /api/actions/set-channel-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI))) mux.Handle("POST /api/actions/revoke-sessions", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI))) mux.Handle("POST /api/actions/delete-messages", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI))) mux.Handle("POST /api/actions/delete-history", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI))) mux.Handle("GET /api/stickers", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetsAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI))) mux.Handle("GET /api/stickers/{id}/documents", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI))) mux.Handle("GET /api/stickers/documents/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI))) mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI))) mux.Handle("POST /api/actions/set-sticker-set-archived", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRenameStickerSetAPI)))
mux.Handle("POST /api/actions/delete-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteStickerSetAPI)))
mux.Handle("POST /api/actions/create-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateStickerSetAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("GET /api/gif-catalog", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogAPI)))
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-category", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/reserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleReserveUsernameAPI)))
mux.Handle("POST /api/actions/unreserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleUnreserveUsernameAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/delete-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
// Official platform verification. Every route needs verification.review; // Official platform verification. Every route needs verification.review;
// clearing an existing badge needs verification.revoke on top of it. // clearing an existing badge needs verification.revoke on top of it.
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI)) mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
@ -140,9 +172,9 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI)) mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI)) mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI)) mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI( mux.Handle("POST /api/actions/revoke-verification", s.scopedRouteAll(
s.requirePermission(permissionVerificationReview, []string{permissionVerificationReview, permissionVerificationRevoke},
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI))))) http.HandlerFunc(s.handleRevokeVerificationAPI)))
// Third-party bot verification. A separate section from the official // Third-party bot verification. A separate section from the official
// verification block above -- separate tables, separate rights, separate routes. // verification block above -- separate tables, separate rights, separate routes.
// Reads and queue decisions need botverification.review; appointing verifiers, // Reads and queue decisions need botverification.review; appointing verifiers,
@ -163,6 +195,26 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI)) 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/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI)) 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/add-server-link", s.serverManage(s.handleAddServerLinkAPI))
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/set-welcome-message-templates", s.serverManage(s.handleSetWelcomeMessageTemplatesAPI))
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
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("POST /api/actions/complete-setup", s.serverManage(s.handleCompleteSetupAPI))
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("GET /api/server/docker-status", s.serverManage(s.handleDockerStatusAPI))
mux.Handle("GET /api/server/check-updates", s.serverManage(s.handleCheckServerUpdatesAPI))
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) { mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found") writeAPIError(w, http.StatusNotFound, "api route not found")
}) })
@ -176,7 +228,11 @@ func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" { if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor return actor
} }
return "admin" // requireAuthAPI always puts the actor in the context, so this is
// unreachable in practice. It returns a name that is obviously not a real
// operator rather than a plausible one: an audit line reading "admin" would
// silently attribute the action to somebody.
return "unknown"
} }
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) { func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
@ -193,7 +249,12 @@ func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
} }
type loginRequest struct { type loginRequest struct {
Secret string `json:"secret"` // Username selects a named account in admin_console_users. Left empty, the
// credential is checked against TELESRV_ADMIN_UI_PASSWORD / _TOKEN instead,
// which keeps the pre-accounts login working and doubles as the way back in
// if the database is unreachable or every named account is locked out.
Username string `json:"username"`
Secret string `json:"secret"`
} }
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it, // sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
@ -213,7 +274,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
} }
if !s.validSecret(req.Secret) { identity, ok := s.authenticateLogin(r.Context(), req)
if !ok {
// One message and one status for every failure mode -- unknown account,
// wrong password, disabled account. Saying which would let anyone with
// the login form enumerate operators.
writeAPIError(w, http.StatusUnauthorized, "invalid credential") writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return return
} }
@ -222,9 +287,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusInternalServerError, err.Error()) writeAPIError(w, http.StatusInternalServerError, err.Error())
return return
} }
permissions := newPanelPermissions(s.cfg.Permissions) permissions := newPanelPermissions(identity.permissions)
value, err := signSession(s.cfg.SessionKey, sessionClaims{ value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin", Actor: identity.actor,
UserID: identity.userID,
Epoch: identity.epoch,
Exp: time.Now().Add(sessionTTL).Unix(), Exp: time.Now().Add(sessionTTL).Unix(),
Nonce: newCommandID("sess"), Nonce: newCommandID("sess"),
Permissions: permissions.List(), Permissions: permissions.List(),
@ -244,7 +311,7 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
}) })
setCSRFCookie(w, csrfToken, sessionTTL) setCSRFCookie(w, csrfToken, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"actor": "admin", "actor": identity.actor,
"permissions": permissions.List(), "permissions": permissions.List(),
"csrf_token": csrfToken, "csrf_token": csrfToken,
"hide_third_party_verification": s.cfg.HideThirdPartyVerification, "hide_third_party_verification": s.cfg.HideThirdPartyVerification,
@ -253,6 +320,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
func (s *server) validSecret(secret string) bool { func (s *server) validSecret(secret string) bool {
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 { if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
// The password quickstart auto-generates for the very first login
// (see identity.Store.TemporaryPasswordMatches) is only good until
// the first-run wizard finishes -- one login's worth of "how do I
// even get in", not a credential anyone actually chose to keep
// around. A password an operator set on purpose, whether by saving
// one from Server Settings or editing .env by hand, never matches
// the stored generated value, so this never touches it.
if s.identity.TemporaryPasswordMatches(s.cfg.Password) && !s.identity.SetupPending() {
return false
}
return true return true
} }
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 { if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
@ -266,14 +343,56 @@ func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// supportedTLLayers returns every MTProto TL schema layer this server binary
// can admit and encode for, oldest first. Probes tlprofile.ResolveProfile
// (the vendored td package's registry) rather than hardcoding a range here,
// so this stays correct as new profiles get added upstream without a
// second place to remember to update. tg.Layer is always the newest one and
// is included by construction, since ResolveProfile(tg.Layer) must succeed.
func supportedTLLayers() []int {
var layers []int
for n := 1; n <= tg.Layer; n++ {
if _, ok := tlprofile.ResolveProfile(n); ok {
layers = append(layers, n)
}
}
return layers
}
// handleSession is what the panel asks on load. It reports the permissions the // handleSession is what the panel asks on load. It reports the permissions the
// session carries, so the UI can hide a section the operator may not use rather // session carries, so the UI can hide a section the operator may not use rather
// than letting them walk into a 403. // than letting them walk into a 403.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) { func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
build := currentBuildMetadata()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"actor": actorFromContext(r.Context()), "actor": actorFromContext(r.Context()),
"permissions": permissionsFromContext(r.Context()).List(), "permissions": permissionsFromContext(r.Context()).List(),
"hide_third_party_verification": s.cfg.HideThirdPartyVerification, "hide_third_party_verification": s.cfg.HideThirdPartyVerification,
// setup_completed gates the first-run wizard -- see
// identity.Store.SetupPending's doc comment for why this reads a
// sentinel file rather than anything in identity.json itself.
"setup_completed": !s.identity.SetupPending(),
// 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,
// api_layers is every MTProto TL schema layer this server binary can
// actually admit and encode for (see tlprofile.ResolveProfile in the
// vendored td package) -- the server is multi-layer (a client on an
// older supported layer still works, not just the newest one), so
// the sidebar shows the whole supported set, not just tg.Layer.
"api_layers": supportedTLLayers(),
// build is this admin binary's own commit -- shown under "Version"
// in the sidebar footer so an operator can tell at a glance which
// build is actually running, independent of the app version string.
"build": map[string]any{
"commit": build.Commit,
"short_commit": build.shortCommit(),
"dirty": build.Dirty,
"build_time": build.BuildTime,
},
}) })
} }
@ -285,13 +404,26 @@ func (s *server) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return return
} }
counts, err := s.read.DashboardCounts(r.Context()) // The two halves hit different tables and neither feeds the other, so the
if err != nil { // page waited for their sum for no reason. Storage in particular is the
writeAPIError(w, http.StatusInternalServerError, err.Error()) // expensive one; running it alongside the counts means the response costs
return // whichever is slower rather than both.
} var (
storage, err := s.read.StorageStats(r.Context()) counts DashboardCounts
if err != nil { storage StorageStatsRow
)
g, gctx := errgroup.WithContext(r.Context())
g.Go(func() error {
var err error
counts, err = s.read.DashboardCounts(gctx)
return err
})
g.Go(func() error {
var err error
storage, err = s.read.StorageStats(gctx)
return err
})
if err := g.Wait(); err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error()) writeAPIError(w, http.StatusInternalServerError, err.Error())
return return
} }
@ -898,10 +1030,13 @@ type createBroadcastAPIRequest struct {
UserIDs []int64 `json:"user_ids,omitempty"` UserIDs []int64 `json:"user_ids,omitempty"`
} }
// handleCreateBroadcastAPI resolves "all users" into an explicit id list // handleCreateBroadcastAPI forwards a broadcast create straight to the admin
// before forwarding to the admin API: the admin service always receives an // API. "all" mode is no longer pre-resolved into an explicit id list here:
// already-resolved recipient list, never "every user" as a live concept it // the admin service snapshots the current eligible user set itself and the
// would have to know how to enumerate itself. // broadcast worker enumerates it incrementally, so "every user" never has
// to cross this boundary (or the one after it) as a potentially huge id
// slice. Only "selected" mode carries UserIDs, already an operator-picked
// list bounded by domain.MaxBroadcastSelectedRecipients.
func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request) {
var body createBroadcastAPIRequest var body createBroadcastAPIRequest
if !decodeAction(w, r, &body) { if !decodeAction(w, r, &body) {
@ -909,16 +1044,7 @@ func (s *server) handleCreateBroadcastAPI(w http.ResponseWriter, r *http.Request
} }
userIDs := body.UserIDs userIDs := body.UserIDs
if body.TargetMode == "all" { if body.TargetMode == "all" {
if s.read == nil { userIDs = nil
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
all, err := s.read.ListAllAccountIDs(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
userIDs = all
} }
req := admin.CreateBroadcastRequest{ req := admin.CreateBroadcastRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"), CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-broadcast"),
@ -1406,6 +1532,52 @@ func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Reques
writeCommandResultAPI(w, result, err) writeCommandResultAPI(w, result, err)
} }
type setAccountAvatarVideoAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
VideoStartTs float64 `json:"video_start_ts"`
}
func (s *server) handleSetAccountAvatarVideoAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarVideoBytes+(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 setAccountAvatarVideoAPIRequest
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 video file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarVideoBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarVideoBytes {
writeAPIError(w, http.StatusBadRequest, "avatar video file is empty or too large")
return
}
req := admin.SetAccountAvatarVideoRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar-video"),
UserID: body.UserID,
FileName: header.Filename,
VideoStartTs: body.VideoStartTs,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar-video", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct { type setUserColorAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
@ -1980,6 +2152,37 @@ func (s *server) handleDeleteUncategorizedGifsAPI(w http.ResponseWriter, r *http
writeCommandResultAPI(w, result, err) writeCommandResultAPI(w, result, err)
} }
// storageManualPurgeAPIRequest mirrors ManualPurgeStorageRequest's frontend
// payload (see StoragePage.tsx's manual purge modal): categories/include_avatars
// go through as native JSON, created_before is an ISO/RFC3339 date string
// (produced by `new Date(x).toISOString()` on the frontend, same convention
// as freeze_until -- see setAccountFrozenAPIRequest.Until) that encoding/json
// parses straight into *time.Time; the field left undefined by the frontend
// (no date entered) decodes to nil, meaning no age filter at all.
type storageManualPurgeAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Categories []string `json:"categories"`
IncludeAvatars bool `json:"include_avatars"`
CreatedBefore *time.Time `json:"created_before"`
}
func (s *server) handleStorageManualPurgeAPI(w http.ResponseWriter, r *http.Request) {
var body storageManualPurgeAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ManualPurgeStorageRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "storage-manual-purge"),
Categories: body.Categories,
IncludeAvatars: body.IncludeAvatars,
CreatedBefore: body.CreatedBefore,
}
result, err := s.callAdminAPI(r.Context(), "/v1/storage/manual-purge", req)
writeCommandResultAPI(w, result, err)
}
type deleteGifCatalogEntryAPIRequest struct { type deleteGifCatalogEntryAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
@ -2239,6 +2442,69 @@ type mintCollectibleUsernameAPIRequest struct {
PurchaseDate flexUnix `json:"purchase_date"` PurchaseDate flexUnix `json:"purchase_date"`
} }
type reserveUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
}
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ReserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.UnreserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params := url.Values{}
for _, name := range []string{"q", "limit", "offset"} {
if v := strings.TrimSpace(q.Get(name)); v != "" {
params.Set(name, v)
}
}
apiPath := "/v1/reserved-usernames"
if enc := params.Encode(); enc != "" {
apiPath += "?" + enc
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "request build failed")
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
return
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(data)
}
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) { if !decodeAction(w, r, &body) {
@ -2438,7 +2704,14 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
writeAPIError(w, http.StatusBadRequest, "invalid limit") writeAPIError(w, http.StatusBadRequest, "invalid limit")
return return
} }
rows, hasMore, err := s.read.ListAccountStorageUsage(r.Context(), offset, limit) sortDesc := query.Get("order") != "asc"
rows, hasMore, err := s.read.ListAccountStorageUsage(
r.Context(),
query.Get("q"),
query.Get("sort"),
sortDesc,
offset,
limit)
if err != nil { if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error()) writeAPIError(w, http.StatusInternalServerError, err.Error())
return return

View file

@ -0,0 +1,425 @@
package main
import (
"encoding/json"
"io"
"net/http"
"path/filepath"
"strings"
"telesrv/internal/admin"
"telesrv/internal/domain"
"telesrv/internal/identity"
)
// 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) ---------------------------------
// serverIdentityAPIResponse extends identity.Info's raw fields with the two
// *effective* fallback welcome-message templates -- s.cfg's
// WelcomeMessage{Phone,Email}Default, i.e. this admin process's own reading
// of TELESRV_WELCOME_MESSAGE_*_TEMPLATE (env var, itself defaulting to the
// compiled-in copy), which matches what owpengram-server falls back to
// whenever the panel override is unset, as long as both processes share the
// same .env (see uiConfig.WelcomeMessagePhoneDefault's doc comment). The
// panel needs both: the raw override (possibly empty) to know whether a
// field is "explicitly set", and the default text to show as "(using
// default: ...)" / to restore on Reset.
type serverIdentityAPIResponse struct {
identity.Info
DefaultWelcomeMessagePhoneTemplate string `json:"default_welcome_message_phone_template"`
DefaultWelcomeMessageEmailTemplate string `json:"default_welcome_message_email_template"`
// DefaultLoginCodeMessageTemplate is the effective fallback text for
// the login-code delivery message (s.cfg.LoginCodeMessageDefault) --
// same "raw override + effective default" contract as the two fields
// above, see their doc comment.
DefaultLoginCodeMessageTemplate string `json:"default_login_code_message_template"`
}
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, serverIdentityAPIResponse{
Info: info,
DefaultWelcomeMessagePhoneTemplate: s.cfg.WelcomeMessagePhoneDefault,
DefaultWelcomeMessageEmailTemplate: s.cfg.WelcomeMessageEmailDefault,
DefaultLoginCodeMessageTemplate: s.cfg.LoginCodeMessageDefault,
})
}
// 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))
}
// --- login-notification templates ---------------------------------------
type setWelcomeMessageTemplatesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
PhoneTemplate string `json:"phone_template"`
EmailTemplate string `json:"email_template"`
}
// handleSetWelcomeMessageTemplatesAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-notification
// message's phone/email template -- see identity.Store.SetWelcomeMessageTemplates
// and domain.ResolveWelcomeMessageTemplate. Deliberately a separate endpoint
// from set-server-identity: brand identity (name/description/icon) and
// login-notification copy are different concerns that happen to share the
// same on-disk identity.json, and keeping them as separate actions/buttons
// means editing one never risks silently blanking the other.
func (s *server) handleSetWelcomeMessageTemplatesAPI(w http.ResponseWriter, r *http.Request) {
var body setWelcomeMessageTemplatesAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-welcome-message-templates")
details := map[string]any{
"phone_template_set": strings.TrimSpace(body.PhoneTemplate) != "",
"email_template_set": strings.TrimSpace(body.EmailTemplate) != "",
}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", nil, "login-notification templates validated", details))
return
}
err := s.identity.SetWelcomeMessageTemplates(body.PhoneTemplate, body.EmailTemplate)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_welcome_message_templates", err, "login-notification templates updated", details))
}
// --- login-code delivery message template --------------------------------
type setLoginCodeMessageTemplateAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Template string `json:"template"`
}
// handleSetLoginCodeMessageTemplateAPI sets (or, with an empty string,
// clears) the admin-panel override for the 777000 login-code delivery
// message -- see identity.Store.SetLoginCodeMessageTemplate and
// domain.ResolveLoginCodeMessageTemplate. A dedicated endpoint (not folded
// into set-welcome-message-templates): this message embeds the actual OTP
// code via the {{code}} placeholder, so a save here carries an extra,
// security-relevant validation the login-notification templates don't
// need -- a template missing {{code}} (or containing it more than once)
// would either silently drop the code from the message or leave it
// ambiguous which occurrence carries it, so it is rejected outright with a
// 422 rather than saved. Clearing the override (empty string) is exempt --
// it always resolves to a valid built-in/env default.
func (s *server) handleSetLoginCodeMessageTemplateAPI(w http.ResponseWriter, r *http.Request) {
var body setLoginCodeMessageTemplateAPIRequest
if !decodeAction(w, r, &body) {
return
}
if t := strings.TrimSpace(body.Template); t != "" {
if err := domain.ValidateLoginCodeMessageTemplate(t); err != nil {
writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
return
}
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-code-message-template")
details := map[string]any{"template_set": strings.TrimSpace(body.Template) != ""}
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", nil, "login-code message template validated", details))
return
}
err := s.identity.SetLoginCodeMessageTemplate(body.Template)
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_login_code_message_template", err, "login-code message template 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))
}
// --- first-run setup wizard ----------------------------------------------
type completeSetupAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
// handleCompleteSetupAPI is the wizard's last step: removes the
// identity.Store setup-pending marker so /api/session stops telling the
// frontend to show it. Everything the wizard actually configures (identity,
// .env, the operator account) is already saved as the operator moves
// through it via the same actions Server Settings/Operators use outside the
// wizard -- this action only marks that the walkthrough happened, so it
// never fails partway through something worth retrying.
func (s *server) handleCompleteSetupAPI(w http.ResponseWriter, r *http.Request) {
var body completeSetupAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "complete-setup")
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", nil, "setup completion validated", nil))
return
}
err := s.identity.MarkSetupComplete()
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", err, "setup marked complete", 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())
}
// handleDockerStatusAPI backs the Services tab's live container list
// (postgres/redis/minio) -- see procctl.Manager.DockerStatus. A "docker
// compose ps" failure (daemon not running, compose file missing) is
// reported as an API error rather than an empty list, so the frontend can
// tell "no services" apart from "couldn't ask Docker".
// handleCheckServerUpdatesAPI backs the Update button's "Check updates"
// state -- a plain git fetch + rev-list count, no pull/build/restart. See
// procctl.Manager.CheckUpdates.
func (s *server) handleCheckServerUpdatesAPI(w http.ResponseWriter, r *http.Request) {
behind, err := s.serverCtl.CheckUpdates(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"commits_behind": behind})
}
func (s *server) handleDockerStatusAPI(w http.ResponseWriter, r *http.Request) {
services, err := s.serverCtl.DockerStatus(r.Context())
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, services)
}
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 both bin/owpengram-server and bin/owpengram-admin-panel, relaunches 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}))
}

View file

@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"telesrv/internal/identity"
)
func TestServerIdentityAPIReportsOverridesAndDefaultsSeparately(t *testing.T) {
store := identity.NewStore(t.TempDir())
srv := &server{
cfg: uiConfig{
WelcomeMessagePhoneDefault: "env phone default",
WelcomeMessageEmailDefault: "env email default",
},
identity: store,
}
// Before any override: GET must report empty raw fields (so the UI can
// tell "unset" apart from "explicitly set to the same text as the
// default"), alongside the effective default text.
req := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp serverIdentityAPIResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.WelcomeMessagePhoneTemplate != "" || resp.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected empty overrides before any Set, got %+v", resp)
}
if resp.DefaultWelcomeMessagePhoneTemplate != "env phone default" || resp.DefaultWelcomeMessageEmailTemplate != "env email default" {
t.Fatalf("expected effective defaults from cfg, got %+v", resp)
}
// Set an override for phone only.
setReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": "custom phone template"
}`))
setRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(setRec, setReq)
if setRec.Code != http.StatusOK {
t.Fatalf("SET status = %d, body = %s", setRec.Code, setRec.Body.String())
}
req2 := httptest.NewRequest(http.MethodGet, "/api/server/identity", nil)
rec2 := httptest.NewRecorder()
srv.handleServerIdentityAPI(rec2, req2)
var resp2 serverIdentityAPIResponse
if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("decode: %v", err)
}
if resp2.WelcomeMessagePhoneTemplate != "custom phone template" {
t.Fatalf("expected phone override to be set, got %+v", resp2)
}
if resp2.WelcomeMessageEmailTemplate != "" {
t.Fatalf("expected email override to stay unset, got %+v", resp2)
}
// Reset (empty string) clears the override back to "unset".
resetReq := httptest.NewRequest(http.MethodPost, "/api/actions/set-welcome-message-templates", strings.NewReader(`{
"reason": "test",
"confirm": true,
"phone_template": ""
}`))
resetRec := httptest.NewRecorder()
srv.handleSetWelcomeMessageTemplatesAPI(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, body = %s", resetRec.Code, resetRec.Body.String())
}
info, err := store.Get()
if err != nil {
t.Fatal(err)
}
if info.WelcomeMessagePhoneTemplate != "" {
t.Fatalf("expected phone override cleared after reset, got %+v", info)
}
}

View file

@ -26,10 +26,28 @@ type sessionClaims struct {
Actor string `json:"actor"` Actor string `json:"actor"`
Exp int64 `json:"exp"` Exp int64 `json:"exp"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
// Permissions is the right set granted to this session, taken from // UserID identifies the admin_console_users row this session belongs to.
// TELESRV_ADMIN_UI_PERMISSIONS at login. It travels inside the signed cookie //
// rather than being re-read per request, so a session keeps the rights it was // Zero means the break-glass operator: whoever logged in with
// issued with, and it cannot be edited by the browser: the HMAC covers it. // TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than a named account. That
// login has no database row, so it is deliberately exempt from the
// per-request revocation check below -- it is the way back in when the
// database is unreachable or every named account has been locked out.
UserID int64 `json:"uid,omitempty"`
// Epoch is the account's token_epoch at the moment this session was minted.
//
// Permissions travel inside the signed cookie, which is fast but means a
// 12-hour session would otherwise keep whatever rights it was issued with
// long after they were taken away. Every request re-reads the account's
// current epoch and refuses the session if it has moved, so disabling an
// operator, editing their rights or changing their password logs them out
// on their very next request.
Epoch int32 `json:"epoch,omitempty"`
// Permissions is the right set granted to this session. For a named account
// it is a snapshot of that row's permissions; for the break-glass operator
// it comes from TELESRV_ADMIN_UI_PERMISSIONS. It cannot be edited by the
// browser: the HMAC covers it. It is still re-read per request for named
// accounts (see Epoch) so an edit narrows access immediately.
Permissions []string `json:"permissions,omitempty"` Permissions []string `json:"permissions,omitempty"`
// CSRF is the double-submit token bound to this session. Binding it into the // CSRF is the double-submit token bound to this session. Binding it into the
// signed claims is what makes the cookie/header pair unforgeable by a sibling // signed claims is what makes the cookie/header pair unforgeable by a sibling

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) { func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
const maxInt64 = int64(9223372036854775807) const maxInt64 = int64(9223372036854775807)
var got admin.MintCollectibleUsernameRequest var got admin.MintCollectibleUsernameRequest

View file

@ -35,7 +35,7 @@ func panelServer(t *testing.T, permissions ...string) *server {
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) { func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
t.Helper() t.Helper()
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`)) req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
srv.routes().ServeHTTP(rec, req) srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
@ -94,7 +94,7 @@ func TestPanelSessionReportsPermissions(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode session: %v", err) t.Fatalf("decode session: %v", err)
} }
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview { if body.Actor != breakGlassUsername || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body) t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
} }
} }
@ -245,7 +245,7 @@ func originRequest(origin, host string) *http.Request {
func TestLoginRefusesAForeignOrigin(t *testing.T) { func TestLoginRefusesAForeignOrigin(t *testing.T) {
srv := panelServer(t, permissionAll) srv := panelServer(t, permissionAll)
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`)) req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
req.Header.Set("Origin", "https://evil.example") req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req) srv.routes().ServeHTTP(rec, req)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script> <script type="module" crossorigin src="/assets/index-DTpNyCcP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css"> <link rel="stylesheet" crossorigin href="/assets/index-P_k7ini0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

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

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "./api"; import { api } from "./api";
import { BootScreen, Shell } from "./components/Layout"; import { BootScreen, Shell } from "./components/Layout";
import { SetupWizard } from "./components/SetupWizard";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PermissionsProvider } from "./permissions"; import { permissionAll, permissionServerManage, PermissionsProvider } from "./permissions";
import { Routes } from "./pages/Routes"; import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing"; import { currentRoute, type RouteState } from "./routing";
import type { AdminSession } from "./types"; import type { AdminSession } from "./types";
@ -37,12 +38,39 @@ export function App() {
} }
if (session === null) { if (session === null) {
return <LoginPage onLogin={setSession} />; return (
<LoginPage
onLogin={(next) => {
// A stale/expired session can be caught on any deep link (a
// bookmark, a page refresh mid-review), landing whoever it belongs
// to on the login form without them having navigated there --
// replaceState rather than a plain navigate() so signing back in
// doesn't leave a "login" entry in browser history to land back on
// via Back. Every login opens on the dashboard, not wherever the
// expired session happened to be.
window.history.replaceState(null, "", "/");
setRoute(currentRoute());
setSession(next);
}}
/>
);
}
// The wizard only ever shows to whoever can actually act on it -- a
// limited operator signing in before setup is finished just sees the
// normal (mostly empty) shell instead of a wizard whose every step would
// 403. setup_completed undefined (an admin binary old enough to predate
// the field) reads as "done", same convention as the type's doc comment.
const canRunSetupWizard = (session.permissions ?? []).some(
(permission) => permission === permissionAll || permission === permissionServerManage
);
if (session.setup_completed === false && canRunSetupWizard) {
return <SetupWizard />;
} }
return ( return (
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}> <PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}> <Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
<Routes route={route} navigate={navigate} /> <Routes route={route} navigate={navigate} />
</Shell> </Shell>
</PermissionsProvider> </PermissionsProvider>

View file

@ -1,4 +1,6 @@
import type { import type {
PublicBranding,
AdminConsoleUserList,
AccountDetail, AccountDetail,
AccountListResponse, AccountListResponse,
AccountStatsResponse, AccountStatsResponse,
@ -22,7 +24,12 @@ import type {
ChannelListResponse, ChannelListResponse,
CollectibleUsernameDetail, CollectibleUsernameDetail,
CollectibleUsernameListResponse, CollectibleUsernameListResponse,
ReservedUsernameListResponse,
CommandResult, CommandResult,
DockerService,
EnvGroup,
ServerIdentity,
ServerStatus,
GroupMessageDetail, GroupMessageDetail,
GroupMessageListResponse, GroupMessageListResponse,
MessageDetail, MessageDetail,
@ -140,16 +147,24 @@ export function errorMessage(error: unknown): string {
export const api = { export const api = {
session: () => request<AdminSession>("/api/session"), session: () => request<AdminSession>("/api/session"),
login: async (secret: string) => { // Reachable before login: the sign-in screen says which server it belongs to.
publicBranding: () => request<PublicBranding>("/api/public/branding"),
publicIconURL: () => `/api/public/icon?t=${Date.now()}`,
// The built-in operator is named "owpengram" and is checked against the
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
// that still works when the database is unreachable. A blank username is
// rejected: there is no anonymous way in.
login: async (secret: string, username = "") => {
const result = await request<AdminLoginResult>("/api/login", { const result = await request<AdminLoginResult>("/api/login", {
method: "POST", method: "POST",
body: JSON.stringify({ secret }) body: JSON.stringify({ username, secret })
}); });
// Stashed here rather than in the caller so no login path can forget it. // Stashed here rather than in the caller so no login path can forget it.
rememberCSRFToken(result.csrf_token); rememberCSRFToken(result.csrf_token);
return result; return result;
}, },
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }), logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
adminUsers: () => request<AdminConsoleUserList>("/api/admin-users"),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`), accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"), accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`), sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
@ -163,6 +178,8 @@ export const api = {
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`), request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
collectibleUsername: (id: string) => collectibleUsername: (id: string) =>
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`), request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
reservedUsernames: (params: URLSearchParams) =>
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
dashboard: () => request<DashboardResponse>("/api/dashboard"), dashboard: () => request<DashboardResponse>("/api/dashboard"),
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"), storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
storageAccounts: (params: URLSearchParams) => storageAccounts: (params: URLSearchParams) =>
@ -228,10 +245,19 @@ export const api = {
gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`, gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }), 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 }), setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
setAccountAvatarVideo: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar-video", { method: "POST", body: form }),
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-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 }), addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"), gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }), createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
addServerLink: () => request<{ link: string }>("/api/server/add-server-link"),
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"),
dockerStatus: () => request<DockerService[]>("/api/server/docker-status"),
checkServerUpdates: () => request<{ commits_behind: number }>("/api/server/check-updates"),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, { action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)

View file

@ -0,0 +1,28 @@
// navigator.clipboard only exists in a secure context (HTTPS, or localhost).
// This admin panel is frequently reached over a plain http:// LAN address
// (e.g. a self-hosted server's own IP), where navigator.clipboard is simply
// undefined -- calling .writeText on it throws "Cannot read properties of
// undefined". Fall back to the old execCommand('copy') path via a hidden,
// off-screen textarea, which still works in that case.
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const ok = document.execCommand("copy");
if (!ok) {
throw new Error("Copy command was not successful");
}
} finally {
document.body.removeChild(textarea);
}
}

View file

@ -3,10 +3,11 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import type { CommandResult } from "../types"; import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui"; import { Alert, JsonBlock } from "./ui";
type ActionTone = "neutral" | "warn" | "danger"; type ActionTone = "neutral" | "warn" | "danger" | "primary";
export function ActionButton({ export function ActionButton({
label, label,
@ -77,7 +78,7 @@ export function ActionButton({
} }
const canConfirm = result?.dry_run && !result.error; const canConfirm = result?.dry_run && !result.error;
const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : ""} ${compact ? "compact-btn" : ""}`; const triggerClass = `btn ${tone === "danger" ? "danger" : tone === "warn" ? "warn" : tone === "primary" ? "primary" : ""} ${compact ? "compact-btn" : ""}`;
const previewPayload = useMemo(() => { const previewPayload = useMemo(() => {
try { try {
return payload(); return payload();
@ -94,7 +95,7 @@ export function ActionButton({
: result?.details; : result?.details;
async function copySecret() { async function copySecret() {
await navigator.clipboard.writeText(secretValue); await copyToClipboard(secretValue);
setSecretCopied(true); setSecretCopied(true);
} }

View file

@ -0,0 +1,81 @@
import { Check, Copy, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert, LoadingSurface } from "./ui";
// AddServerLinkModal shows a ready-made owpg://addserver link for this
// exact server (host+port only -- see the Go handler's doc comment for why
// name/description/key/DC are deliberately never embedded in it) -- an
// operator hands this out (a website button, a QR code, a message
// elsewhere) and the desktop/Android client's "Add Server" form opens
// pre-filled from it, fetching the rest straight from this server itself.
export function AddServerLinkModal({ onClose }: { onClose: () => void }) {
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
api.addServerLink()
.then((result) => {
if (!cancelled) setLink(result.link);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, []);
async function copy() {
if (!link) return;
try {
await copyToClipboard(link);
setCopied(true);
} catch (err) {
setError(errorMessage(err));
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal add-server-link-modal" role="dialog" aria-modal="true" aria-label={"Share server"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Server"}</div>
<h2>{"Share server"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"Share this link (a button, a QR code, a message) so anyone with the OwpenGram client can add this server in one tap. It only carries the address and port -- the client fetches the name, description, and key directly from the server itself, so the link can never be tampered with to point someone at a fake identity for this address."}</p>
{error && <Alert>{error}</Alert>}
{!link && !error && <LoadingSurface label={"Building link..."} />}
{link && (
<div className="add-server-link-field">
<label className="form-field">
<span>{"owpg:// link"}</span>
<textarea value={link} readOnly rows={2} onFocus={(event) => event.currentTarget.select()} />
</label>
<button className="btn primary icon-text" type="button" onClick={() => void copy()}>
<Copy size={15} /> {copied ? "Copy again" : "Copy link"}
</button>
{copied && (
<div className="secret-reveal">
<div className="secret-reveal-label"><Check size={14} /> {"Copied to clipboard."}</div>
</div>
)}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
import { BgIcons } from "./BgIcons";
// The product's background: three blurred orbs that drift with the pointer,
// and the field of slowly moving icons over them. Ported from the marketing
// site so the console reads as the same product.
//
// One component for both places it appears -- the sign-in screen and the
// workspace behind every page -- because two copies of a parallax handler is
// how they end up subtly different.
export function AppBackground({ className = "" }: { className?: string }) {
const orbsRef = useRef<HTMLDivElement>(null);
// The orbs drift with the pointer, the same 30px parallax the site uses.
// Written straight to the node instead of through state: this fires on every
// mouse move, and re-rendering the tree for a background offset would be a
// lot of work to move three blurred circles.
useEffect(() => {
// Someone who asked the system for less motion gets none of this. The CSS
// drift is already disabled for them, and a JS-driven transform would walk
// straight past that preference.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
function onMove(event: MouseEvent) {
const node = orbsRef.current;
if (!node) return;
const x = (event.clientX / window.innerWidth - 0.5) * 30;
const y = (event.clientY / window.innerHeight - 0.5) * 30;
node.style.transform = `translate(${x}px, ${y}px)`;
}
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
return (
<div className={`app-background ${className}`.trim()} aria-hidden="true">
<div className="bg-orbs" ref={orbsRef}>
<div className="bg-orb bg-orb--1" />
<div className="bg-orb bg-orb--2" />
<div className="bg-orb bg-orb--3" />
</div>
<BgIcons />
</div>
);
}

View file

@ -5,17 +5,20 @@ export function AppLink({
href, href,
navigate, navigate,
className, className,
title,
children children
}: { }: {
href: string; href: string;
navigate: Navigate; navigate: Navigate;
className?: string; className?: string;
title?: string;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<a <a
className={className} className={className}
href={href} href={href}
title={title}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
navigate(href); navigate(href);

View file

@ -13,10 +13,13 @@ type AvatarModalKind = "user" | "channel";
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) { export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState(""); const [previewURL, setPreviewURL] = useState("");
const [videoStartTs, setVideoStartTs] = useState("0");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const isVideo = kind === "user" && !!file && file.type.startsWith("video/");
useEffect(() => { useEffect(() => {
if (!file) { if (!file) {
setPreviewURL(""); setPreviewURL("");
@ -29,7 +32,7 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
async function submit() { async function submit() {
if (!file) { if (!file) {
setError("Choose an image file first."); setError("Choose an image or video file first.");
return; return;
} }
if (!reason.trim()) { if (!reason.trim()) {
@ -41,9 +44,13 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
try { try {
const idField = kind === "channel" ? "channel_id" : "user_id"; const idField = kind === "channel" ? "channel_id" : "user_id";
const form = new FormData(); const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id })); const metadata: Record<string, unknown> = { command_id: "", reason: reason.trim(), confirm: true, [idField]: id };
if (isVideo) {
metadata.video_start_ts = Number(videoStartTs) || 0;
}
form.set("metadata", JSON.stringify(metadata));
form.set("file", file, file.name); form.set("file", file, file.name);
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form); const result = kind === "channel" ? await api.setChannelAvatar(form) : isVideo ? await api.setAccountAvatarVideo(form) : await api.setAccountAvatar(form);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
return; return;
@ -71,11 +78,32 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
</div> </div>
<div className="command-body"> <div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}> <label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /> <input
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />} type="file"
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span> accept={kind === "user" ? "image/png,image/jpeg,image/webp,video/mp4" : "image/png,image/jpeg,image/webp"}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewURL ? (
isVideo ? (
<video className="gift-file-icon" src={previewURL} style={{ objectFit: "cover" }} muted loop autoPlay />
) : (
<img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} />
)
) : (
<ImagePlus size={22} />
)}
<span className="gift-file-copy">
<span className="gift-field-label">{"New avatar"}</span>
<strong>{file ? file.name : kind === "user" ? "Choose a JPEG, PNG, WebP image, or MP4 video" : "Choose a JPEG, PNG, or WebP image"}</strong>
</span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span> <span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label> </label>
{isVideo && (
<label className="gift-reason-field">
<span>{"Video start (seconds)"}</span>
<input type="number" min="0" step="0.1" value={videoStartTs} onChange={(event) => setVideoStartTs(event.target.value)} />
</label>
)}
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label> <label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
</div> </div>

View file

@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
// The drifting icon field from the marketing site (owpengram-site's
// BgIcons.vue), ported so the console's sign-in screen looks like the same
// product rather than a different one.
//
// Same 28 particles, same sizes, speeds, opacities and repulsion as the
// original -- this is meant to match, not to be a second interpretation of the
// idea.
// Inner markup for each icon, drawn as strokes on a 24x24 viewBox. These are
// source constants, never anything a user supplied, which is what makes
// injecting them as HTML below safe.
const ICON_PATHS = [
`<rect x="2.5" y="3.5" width="19" height="12" rx="2"/><path d="M8 20h8M12 15.5V20"/>`,
`<rect x="3.5" y="4" width="17" height="6" rx="2"/><rect x="3.5" y="14" width="17" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>`,
`<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 7l9 6.5L21 7"/>`,
`<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.6 2.5 4 5.7 4 9s-1.4 6.5-4 9c-2.6-2.5-4-5.7-4-9s1.4-6.5 4-9z"/>`,
`<rect x="7" y="3" width="10" height="18" rx="2.4"/><path d="M11 18h2"/>`,
`<path d="M8 6l-6 6 6 6M16 6l6 6-6 6"/>`,
`<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>`,
`<path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6l7-3z"/><path d="M8.8 12.2l2.1 2.1 4.3-4.3"/>`,
`<path d="M21.5 3.5l-19 7.5 5.5 2.5 3 5 3-2 5.5 5z"/><path d="M11 14l8.5-8.5"/>`,
`<rect x="4.5" y="11" width="15" height="9" rx="2.2"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9"/><path d="M12 15v2"/>`,
`<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>`,
`<circle cx="12" cy="12" r="2.5"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>`,
`<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`,
`<path d="M12 2l3.1 6.3L22 9.5l-5 4.9 1.2 7L12 17.3 5.8 21.4 7 14.4l-5-4.9 6.9-1.2z"/>`,
`<path d="M14.5 17.5L19 13l-4.5-4.5M9.5 6.5L5 11l4.5 4.5"/>`,
`<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
`<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/><path d="M15 12h4M17 10v4"/>`,
`<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>`,
`<circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>`,
`<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>`,
`<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>`,
`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>`,
`<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
`<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>`,
`<circle cx="12" cy="12" r="10"/><path d="M12 6v12M6 12h12"/>`
];
const PARTICLE_COUNT = 28;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
size: number;
rot: number;
rotSpeed: number;
};
export function BgIcons() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Someone who asked the system for less motion gets a still field rather
// than twenty-eight things drifting across their screen.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
const nodes = Array.from(host.querySelectorAll<SVGSVGElement>(".bg-icon"));
let particles: Particle[] = [];
function seed() {
const w = window.innerWidth;
const h = window.innerHeight;
const margin = 60;
particles = nodes.map((node) => {
const size = 22 + Math.random() * 30;
node.style.width = `${size}px`;
node.style.height = `${size}px`;
node.style.opacity = String(0.18 + Math.random() * 0.12);
return {
x: margin + Math.random() * Math.max(1, w - margin * 2),
y: margin + Math.random() * Math.max(1, h - margin * 2),
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size,
rot: Math.random() * 360,
rotSpeed: (Math.random() - 0.5) * 0.08
};
});
}
let frame = 0;
function tick() {
const w = window.innerWidth;
const h = window.innerHeight;
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
a.x += a.vx;
a.y += a.vy;
a.rot += a.rotSpeed;
// Bounce off the viewport edges so the field stays on screen.
const half = a.size / 2;
if (a.x < half) { a.x = half; a.vx *= -1; }
if (a.x > w - half) { a.x = w - half; a.vx *= -1; }
if (a.y < half) { a.y = half; a.vy *= -1; }
if (a.y > h - half) { a.y = h - half; a.vy *= -1; }
// Gentle mutual repulsion: without it the drift eventually clumps the
// icons into a corner and the field stops reading as a field.
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = (a.size + b.size) / 2 + 20;
if (dist < minDist && dist > 0.01) {
const force = ((minDist - dist) / minDist) * 0.02;
const nx = dx / dist;
const ny = dy / dist;
a.vx -= nx * force;
a.vy -= ny * force;
b.vx += nx * force;
b.vy += ny * force;
}
}
}
// Written straight to the nodes: this runs every frame, and putting 28
// positions through React state would re-render the sign-in form sixty
// times a second to move some background art.
for (let i = 0; i < nodes.length; i++) {
const p = particles[i];
nodes[i].style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg)`;
}
frame = requestAnimationFrame(tick);
}
seed();
frame = requestAnimationFrame(tick);
window.addEventListener("resize", seed);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", seed);
};
}, []);
return (
<div className="bg-icons" aria-hidden="true" ref={hostRef}>
{Array.from({ length: PARTICLE_COUNT }, (_, i) => (
<svg
key={i}
className="bg-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
dangerouslySetInnerHTML={{ __html: ICON_PATHS[i % ICON_PATHS.length] }}
/>
))}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { Check, Copy, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert } from "./ui"; import { Alert } from "./ui";
// CopyBotTokenModal writes a non-system bot's token straight to the // CopyBotTokenModal writes a non-system bot's token straight to the
@ -34,7 +35,7 @@ export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose:
setError(result.error || "No token returned."); setError(result.error || "No token returned.");
return; return;
} }
await navigator.clipboard.writeText(token); await copyToClipboard(token);
setCopied(true); setCopied(true);
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));

View file

@ -8,9 +8,11 @@ import { MultiUserPicker } from "./EntityPicker";
type TargetMode = "all" | "selected"; type TargetMode = "all" | "selected";
// CreateBroadcastModal composes the message and target list, then hands off to // CreateBroadcastModal composes the message and target list, then hands off to
// ActionButton for the usual dry-run/confirm flow. "All users" is resolved to an // ActionButton for the usual dry-run/confirm flow. "All users" is never
// explicit id list server-side (cmd/telesrv-admin/server.go), not here -- the // resolved into an id list at all -- the admin service snapshots the
// picker only ever deals with an actual, visible list of accounts. // current eligible user set itself and a background worker enumerates it
// incrementally, so this only ever sends user_ids for "selected" mode,
// where the picker deals with an actual, visible list of accounts.
export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { export function CreateBroadcastModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [targetMode, setTargetMode] = useState<TargetMode>("all"); const [targetMode, setTargetMode] = useState<TargetMode>("all");

View file

@ -1,6 +1,7 @@
import { import {
AtSign, AtSign,
BadgeCheck, BadgeCheck,
Ban,
Bot, Bot,
ChevronDown, ChevronDown,
Database, Database,
@ -9,20 +10,62 @@ import {
LogOut, LogOut,
Megaphone, Megaphone,
MessageSquareText, MessageSquareText,
PanelLeftClose,
PanelLeftOpen,
Settings,
UserCog,
UserRound,
Share2,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
Smile, Smile,
Stamp, Stamp,
Users, Users,
Zap,
Sticker Sticker
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api"; import { api, errorMessage } from "../api";
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions"; import { clearAdminCache } from "../lib/cache";
import { permissionBotVerificationReview, permissionServerManage, permissionAdminsManage,
permissionAccountsRead,
permissionChannelsRead,
permissionBotsRead,
permissionMessagesRead,
permissionModerationReview,
permissionBroadcastsRead,
permissionStorageRead,
permissionContentRead,
permissionUsernamesRead,
permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing"; import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme"; import { ThemeSwitch } from "../theme";
import { AddServerLinkModal } from "./AddServerLinkModal";
import { AppBackground } from "./AppBackground";
import { AppLink } from "./AppLink"; import { AppLink } from "./AppLink";
// Compresses a sorted (or unsorted) list of layer numbers into run-length
// ranges for the compact sidebar label, e.g. [225,226,227,228,229] -> "225-229",
// or [225,226,228] -> "225-226, 228" if the server ever supports a
// non-contiguous set. The full list is still always shown in the tooltip.
function formatLayerRanges(layers: number[]): string {
const sorted = [...layers].sort((a, b) => a - b);
const parts: string[] = [];
let start = sorted[0];
let prev = sorted[0];
for (let i = 1; i <= sorted.length; i++) {
const current = sorted[i];
if (current === prev + 1) {
prev = current;
continue;
}
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
start = current;
prev = current;
}
return parts.join(", ");
}
export function BootScreen() { export function BootScreen() {
return ( return (
<div className="boot-screen"> <div className="boot-screen">
@ -40,12 +83,16 @@ export function BootScreen() {
export function Shell({ export function Shell({
actor, actor,
apiLayers,
build,
route, route,
navigate, navigate,
onLogout, onLogout,
children children
}: { }: {
actor: string; actor: string;
apiLayers?: number[];
build?: { commit: string; short_commit: string; dirty: boolean; build_time: string };
route: RouteState; route: RouteState;
navigate: Navigate; navigate: Navigate;
onLogout: () => void; onLogout: () => void;
@ -54,97 +101,282 @@ export function Shell({
// The verification queue is hidden for a session without verification.review: // The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well). // the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview); const canReviewVerification = useCan(permissionVerificationReview);
const canManageAdmins = useCan(permissionAdminsManage);
// Each section entry is hidden without the right to open it: the route is
// gated server-side either way, so showing it would only lead to a 403.
const canReadAccounts = useCan(permissionAccountsRead);
const canReadChannels = useCan(permissionChannelsRead);
const canReadBots = useCan(permissionBotsRead);
const canReadMessages = useCan(permissionMessagesRead);
const canReviewModeration = useCan(permissionModerationReview);
const canReadBroadcasts = useCan(permissionBroadcastsRead);
const canReadStorage = useCan(permissionStorageRead);
const canReadContent = useCan(permissionContentRead);
const canReadUsernames = useCan(permissionUsernamesRead);
// Same reasoning for the third-party queue, which has its own right: the two // 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. // sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview); const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
// Remembered per browser: an operator who works in a narrow window should not
// have to re-collapse the navigation on every visit. A failed read (private
// mode, blocked storage) just means the default.
const [navCollapsed, setNavCollapsed] = useState(() => {
try {
return localStorage.getItem("owpengram.nav.collapsed") === "1";
} catch {
return false;
}
});
function toggleNav() {
setNavCollapsed((current) => {
const next = !current;
try {
localStorage.setItem("owpengram.nav.collapsed", next ? "1" : "0");
} catch {
// Not being able to remember the choice is not a reason to refuse it.
}
return next;
});
}
const [addServerLinkOpen, setAddServerLinkOpen] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState("");
// "Connect" is the short-cut version of Share: instead of showing a link to
// copy elsewhere, it opens the owpg://addserver link (host+port only --
// see the Go handler's doc comment for why nothing else ever goes in it)
// right here in this browser, so if an OwpenGram client is registered for
// that scheme on this machine, it launches straight into "Add Server"
// pre-filled for the server this very admin panel manages, fetching the
// rest (name/description/key) straight from it.
async function connectThisServer() {
setConnecting(true);
setConnectError("");
try {
const result = await api.addServerLink();
window.location.href = result.link;
} catch (err) {
setConnectError(errorMessage(err));
} finally {
setConnecting(false);
}
}
// Server identity (name/icon) is admin-editable per Server Settings ->
// Server identity, and takes over the sidebar branding when set -- the
// operator's own server should look like their server, not like the
// "OwpenGram" reference build, once they've bothered to configure it.
// Only fetched for sessions that can even see Server Settings; a session
// without that permission just gets the default branding.
const [identity, setIdentity] = useState<{ name: string; iconExt?: string } | null>(null);
useEffect(() => {
if (!canManageServer) return;
api.serverIdentity()
.then((info) => setIdentity({ name: info.name, iconExt: info.icon_ext }))
.catch(() => undefined);
}, [canManageServer]);
const [brandIconFailed, setBrandIconFailed] = useState(false);
const brandName = identity?.name?.trim() || "OwpenGram";
const brandIconSrc = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
// The browser tab (title + favicon) follows the same custom-identity
// override as the sidebar brand above, so a re-labeled server actually
// looks like itself in the tab strip too, not just inside the app.
useEffect(() => {
document.title = `${brandName} Admin`;
}, [brandName]);
useEffect(() => {
let link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
const linkEl = link;
const src = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
// Browsers render the favicon file as-is -- they don't apply the
// sidebar's CSS border-radius to it, so a square-cornered source image
// (the default logo, or whatever shape an operator's uploaded icon
// happens to be) shows up square in the tab strip. Bake the circular
// mask into the actual pixels instead, the same way an app icon export
// would, so the tab matches the round mark everywhere else in the UI.
let cancelled = false;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
if (cancelled) {
return;
}
const size = 64;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
linkEl.href = src;
return;
}
ctx.save();
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(img, 0, 0, size, size);
ctx.restore();
linkEl.href = canvas.toDataURL("image/png");
};
img.onerror = () => {
// Cross-origin or load failure: fall back to the raw image rather
// than leaving the tab with no favicon at all.
if (!cancelled) {
linkEl.href = src;
}
};
img.src = src;
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity?.iconExt, brandIconFailed]);
// Third-party verification is additionally hidden by default (not fully // Third-party verification is additionally hidden by default (not fully
// finished) regardless of what the session was granted -- see permissions.tsx. // finished) regardless of what the session was granted -- see permissions.tsx.
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden(); const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() { async function logout() {
await api.logout().catch(() => undefined); await api.logout().catch(() => undefined);
// Drop the cached figures with the session. Without this the next operator
// to sign in on this tab would open the dashboard on the previous one's
// numbers -- briefly, but from an account that may not be allowed to see
// them at all.
clearAdminCache();
onLogout(); onLogout();
} }
return ( return (
<div className="shell"> <div className={`shell ${navCollapsed ? "shell--nav-collapsed" : ""}`.trim()}>
<aside className="sidebar"> <aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}> <AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span> <span className="brand-mark"><img src={brandIconSrc} alt={brandName} onError={() => setBrandIconFailed(true)} /></span>
<span> <span>
<strong>OwpenGram</strong> <strong>{brandName}</strong>
<small>{"Admin Console"}</small> <small>{"Admin Console"}</small>
</span> </span>
</AppLink> </AppLink>
<div className="sidebar-label">{"Navigation"}</div> <div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={"Primary navigation"}> <nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink> <NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink> {canReadAccounts && (
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink> <NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink> )}
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink> {canReadChannels && (
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink> <NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
)}
{canReadBots && (
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
)}
{canReviewModeration && (
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
)}
{canReadBroadcasts && (
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
)}
{canReviewVerification && ( {canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink> <NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)} )}
{canReviewBotVerification && !thirdPartyVerificationHidden && ( {canReviewBotVerification && !thirdPartyVerificationHidden && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink> <NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)} )}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink> {canReadUsernames && (
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink> <NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink> )}
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink> {canReadUsernames && (
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink> <NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}> )}
<button {canReadStorage && (
className="nav-section-toggle" <NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
type="button" )}
aria-expanded={messagesOpen} {canReadContent && (
onClick={() => setMessagesOpen((open) => !open)} <NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
)}
{canReadMessages && (
<NavLink
icon={<MessageSquareText size={16} />}
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages")}
> >
<MessageSquareText size={16} /> {"Messages"}
<span>{"Messages"}</span> </NavLink>
<ChevronDown className="nav-section-chevron" size={15} /> )}
</button> {canManageAdmins && (
{messagesOpen && ( <NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
<div className="nav-children"> )}
<NavLink {canManageServer && (
href="/messages/private" <NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
route={route} )}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
{"Private"}
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
{"Groups"}
</NavLink>
</div>
)}
</div>
</nav> </nav>
<div className="sidebar-status">
<span className="sidebar-label">{"Version: O7"}</span>
{apiLayers && apiLayers.length > 0 && (
<span className="sidebar-label sidebar-api-layer" title={`Layers: ${apiLayers.join(", ")}`}>
{`API layers: ${formatLayerRanges(apiLayers)}`}
</span>
)}
{build?.short_commit && (
<span className="sidebar-label sidebar-build" title={build.commit + (build.dirty ? " (uncommitted changes)" : "")}>
{`Build: ${build.short_commit}${build.dirty ? "+" : ""}`}
</span>
)}
</div>
{canManageServer && (
<div className="sidebar-server-actions">
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Connect this browser's client to this server"}
disabled={connecting}
onClick={() => void connectThisServer()}
>
<Zap size={15} /> {"Connect"}
</button>
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Share server (get an add-server link)"}
onClick={() => setAddServerLinkOpen(true)}
>
<Share2 size={15} /> {"Share"}
</button>
</div>
)}
{connectError && <div className="sidebar-server-action-error">{connectError}</div>}
{addServerLinkOpen && <AddServerLinkModal onClose={() => setAddServerLinkOpen(false)} />}
</aside> </aside>
<div className="workspace"> <div className="workspace">
<header className="topbar"> <header className="topbar">
<div> <div className="topbar-lead">
<button
className="icon-btn nav-toggle"
type="button"
onClick={toggleNav}
aria-expanded={!navCollapsed}
aria-label={navCollapsed ? "Expand navigation" : "Collapse navigation"}
title={navCollapsed ? "Expand navigation" : "Collapse navigation"}
>
{navCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
<h1>{routeTitle(route.path)}</h1> <h1>{routeTitle(route.path)}</h1>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<ThemeSwitch /> <ThemeSwitch />
<span className="actor-pill">{`Actor: ${actor}`}</span> <span className="actor-pill"><UserRound size={14} /> {actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}> <button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {"Log out"} <LogOut size={16} /> {"Log out"}
</button> </button>
@ -152,6 +384,9 @@ export function Shell({
</header> </header>
<main className="content">{children}</main> <main className="content">{children}</main>
</div> </div>
{/* Behind everything, fixed to the viewport. The sidebar and topbar paint
over it, so it shows through the working area only. */}
<AppBackground className="app-background--workspace" />
</div> </div>
); );
} }
@ -173,9 +408,16 @@ function NavLink({
}) { }) {
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href); const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
return ( return (
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}> <AppLink
className={`nav-item ${active ? "active" : ""}`}
href={href}
navigate={navigate}
// The label is hidden when the sidebar is collapsed, so it moves to the
// tooltip -- an icon rail with no names is a memory test.
title={typeof children === "string" ? children : undefined}
>
{icon ?? <span aria-hidden="true" className="nav-dot" />} {icon ?? <span aria-hidden="true" className="nav-dot" />}
<span>{children}</span> <span className="nav-item-label">{children}</span>
</AppLink> </AppLink>
); );
} }

View file

@ -0,0 +1,163 @@
import {
Contact,
Dice5,
FileText,
Gift,
Image,
Link2,
ListChecks,
MapPin,
Radio,
Settings2,
Sparkles,
type LucideIcon
} from "lucide-react";
import type { ReactNode } from "react";
import { formatBytes } from "../lib/format";
// What a message actually was, rendered the way a person reads it: the text
// first, then what was attached to it. The database rows behind it are still
// available further down each detail page, but an operator opening a message
// is nearly always asking "what does it say", and answering that with a JSON
// dump made them decode the answer themselves.
// Mirrors domain.MessageMedia's JSON. Everything is optional because the
// snapshot is written by a server that keeps gaining media kinds -- an unknown
// one has to degrade to "there is media of kind X" rather than blow up.
type MediaSnapshot = {
kind?: string;
document?: { file_name?: string; mime_type?: string; size?: number; duration?: number };
photo?: { id?: number | string };
contact?: { first_name?: string; last_name?: string; phone_number?: string };
geo?: { lat?: number; long?: number };
geo_live?: { lat?: number; long?: number; period?: number };
venue?: { title?: string; address?: string };
poll?: { question?: string; answers?: unknown[]; closed?: boolean };
web_page?: { url?: string; title?: string; site_name?: string };
story?: { id?: number };
todo?: { title?: string };
dice?: { emoticon?: string; value?: number };
giveaway?: unknown;
service_action?: { kind?: string; type?: string };
spoiler?: boolean;
ttl_seconds?: number;
voice?: boolean;
round?: boolean;
video?: boolean;
};
function parseMedia(raw: string | undefined): MediaSnapshot | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as MediaSnapshot;
if (!parsed || typeof parsed !== "object") return null;
// "{}" is what a text-only message stores, not a media object.
if (Object.keys(parsed).length === 0) return null;
return parsed;
} catch {
return null;
}
}
// describeMedia turns the snapshot into a line a person can read plus the icon
// that goes with it. Unknown kinds still get a row, named after the kind.
function describeMedia(media: MediaSnapshot): { icon: LucideIcon; title: string; detail: string } {
const kind = media.kind ?? "";
switch (kind) {
case "photo":
return { icon: Image, title: "Photo", detail: media.photo?.id ? `id ${media.photo.id}` : "" };
case "document": {
const doc = media.document ?? {};
const bits = [doc.mime_type, doc.size ? formatBytes(String(doc.size)) : "", doc.duration ? `${doc.duration}s` : ""].filter(Boolean);
const title = media.voice ? "Voice message" : media.round ? "Round video" : media.video ? "Video" : "File";
return { icon: FileText, title, detail: [doc.file_name, bits.join(" · ")].filter(Boolean).join(" — ") };
}
case "contact": {
const c = media.contact ?? {};
const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
return { icon: Contact, title: "Contact", detail: [name, c.phone_number].filter(Boolean).join(" · ") };
}
case "geo":
return { icon: MapPin, title: "Location", detail: media.geo ? `${media.geo.lat}, ${media.geo.long}` : "" };
case "geo_live":
return { icon: MapPin, title: "Live location", detail: media.geo_live ? `${media.geo_live.lat}, ${media.geo_live.long}` : "" };
case "venue":
return { icon: MapPin, title: "Venue", detail: [media.venue?.title, media.venue?.address].filter(Boolean).join(" — ") };
case "poll":
return {
icon: ListChecks,
title: media.poll?.closed ? "Poll (closed)" : "Poll",
detail: [media.poll?.question, media.poll?.answers ? `${media.poll.answers.length} options` : ""].filter(Boolean).join(" — ")
};
case "web_page":
return { icon: Link2, title: "Link preview", detail: [media.web_page?.title, media.web_page?.url].filter(Boolean).join(" — ") };
case "story":
return { icon: Sparkles, title: "Story", detail: media.story?.id ? `id ${media.story.id}` : "" };
case "todo":
return { icon: ListChecks, title: "Checklist", detail: media.todo?.title ?? "" };
case "dice":
return { icon: Dice5, title: "Dice", detail: [media.dice?.emoticon, media.dice?.value].filter(Boolean).join(" ") };
case "giveaway":
return { icon: Gift, title: "Giveaway", detail: "" };
case "service":
return { icon: Settings2, title: "Service action", detail: media.service_action?.kind ?? media.service_action?.type ?? "" };
default:
return { icon: Radio, title: kind ? `Media (${kind})` : "Media", detail: "" };
}
}
export function MessageView({
body,
media,
sender,
meta,
badges
}: {
body: string;
media?: string;
// Who sent it, already resolved to something readable by the caller.
sender: string;
// When, and anything else that belongs on the header line.
meta: string;
badges?: ReactNode;
}) {
const parsed = parseMedia(media);
const described = parsed ? describeMedia(parsed) : null;
const Icon = described?.icon;
const text = body?.trim() ?? "";
return (
<section className="message-view">
<div className="message-view-head">
<div>
<strong>{sender}</strong>
<small>{meta}</small>
</div>
{badges && <div className="entity-badges">{badges}</div>}
</div>
<div className="message-bubble">
{text
? <p className="message-text">{text}</p>
: <p className="message-text empty">{described ? "No caption" : "No text"}</p>}
{described && Icon && (
<div className="message-attachment">
<span className="message-attachment-icon"><Icon size={16} /></span>
<span className="message-attachment-copy">
<strong>{described.title}</strong>
{described.detail && <small>{described.detail}</small>}
</span>
</div>
)}
{parsed && (parsed.spoiler || parsed.ttl_seconds) && (
<div className="message-flags">
{parsed.spoiler && <span className="chip">{"Spoiler"}</span>}
{parsed.ttl_seconds ? <span className="chip">{`Self-destructs after ${parsed.ttl_seconds}s`}</span> : null}
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,513 @@
import { ArrowRight, Check, ImagePlus, Loader2, Rocket, UserPlus } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { RestartOverlay, ServerIconModal, useAdminRestartWatcher } from "../pages/ServerSettingsPage";
import { ThemeSwitch } from "../theme";
import { AppBackground } from "./AppBackground";
import { Alert } from "./ui";
// The first-run wizard: shown instead of the normal shell exactly once, when
// GET /api/session answers setup_completed=false (see
// identity.Store.SetupPending and cmd/telesrv-admin/server.go's
// handleSession). It covers the same ground
// tui-panel/server-panel.py's SetupWizardScreen used to ask for in the
// terminal before the server could even start -- server identity, the public
// network fields a real deployment needs, and a named operator account to
// replace the generated break-glass password -- except every field here
// already has a working default (see quickstart's bootstrap_env), so nothing
// blocks Start anymore. This just walks through customizing it.
//
// Every step's "Continue" calls the same /api/actions/* route the equivalent
// Server Settings / Operators screen uses, with a fixed reason instead of an
// operator-typed one and confirm:true immediately (no dry-run screen). The
// rest of the panel asks an operator to justify a change to a live,
// populated deployment; here the operator IS the only account that has ever
// existed, configuring a server nobody else is using yet -- asking "why are
// you naming your own server" is friction with no audit value.
const WIZARD_REASON = "Set from the first-run setup wizard";
type StepId = "welcome" | "identity" | "network" | "botapi" | "account" | "done";
const STEP_ORDER: StepId[] = ["welcome", "identity", "network", "botapi", "account", "done"];
const STEP_LABEL: Record<StepId, string> = {
welcome: "Welcome",
identity: "Identity",
network: "Network",
botapi: "Bot API",
account: "Account",
done: "Done"
};
// No prop for "leave the wizard early": this only ever shows on a genuinely
// first-ever start, before there is a real deployment for a Skip to defer
// anything about -- see the Welcome step. The only way out is Done's
// "Finish setup & restart", which reloads the page once the new process
// answers; that reload is what dismisses this component (a fresh
// /api/session comes back with setup_completed=true).
export function SetupWizard() {
const [step, setStep] = useState<StepId>("welcome");
function goTo(next: StepId) {
setStep(next);
}
return (
<main className="login-page setup-wizard-page">
<AppBackground />
<section className="login-panel setup-wizard-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span>
<strong>{"Let's set up your server"}</strong>
<small>{"First-run setup"}</small>
</span>
</div>
<div className="login-head-actions">
<ThemeSwitch />
</div>
</div>
<div className="wizard-steps">
{STEP_ORDER.map((id, index) => {
const currentIndex = STEP_ORDER.indexOf(step);
const state = index === currentIndex ? "active" : index < currentIndex ? "done" : "";
return (
<div key={id} className={`command-step ${state}`}>
<span>{index < currentIndex ? <Check size={12} /> : index + 1}</span>
<strong>{STEP_LABEL[id]}</strong>
</div>
);
})}
</div>
{step === "welcome" && <WelcomeStep onNext={() => goTo("identity")} />}
{step === "identity" && <IdentityStep onNext={() => goTo("network")} />}
{step === "network" && <NetworkStep onNext={() => goTo("botapi")} />}
{step === "botapi" && <BotApiStep onNext={() => goTo("account")} />}
{step === "account" && <AccountStep onNext={() => goTo("done")} />}
{step === "done" && <DoneStep />}
</section>
</main>
);
}
function WizardActions({ children }: { children: ReactNode }) {
return <div className="wizard-actions">{children}</div>;
}
function WelcomeStep({ onNext }: { onNext: () => void }) {
return (
<div className="wizard-step-body">
<p className="wizard-welcome-greeting">{"Hi!"}</p>
<p>
{"Let's get your server set up -- a name, an address for clients, and an account of "}
{"your own. Takes about a minute, and everything here stays editable later."}
</p>
<WizardActions>
<button className="btn primary icon-text" type="button" onClick={onNext}>
{"Get started"} <ArrowRight size={15} />
</button>
</WizardActions>
</div>
);
}
function IdentityStep({ onNext }: { onNext: () => void }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [iconExt, setIconExt] = useState<string | undefined>(undefined);
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconBust, setIconBust] = useState(0);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.serverIdentity()
.then((info) => {
if (cancelled) return;
setName(info.name);
setDescription(info.description);
setIconExt(info.icon_ext);
setLoaded(true);
})
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
return () => { cancelled = true; };
}, []);
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/set-server-identity", {
command_id: "", reason: WIZARD_REASON, confirm: true, name, description
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Shown to clients when they add this server, and in the sidebar here."}</p>
{error && <Alert>{error}</Alert>}
<div className="wizard-identity-row">
<div className="avatar-edit-slot">
{iconExt ? (
<img className="avatar-photo-img" src={api.serverIconURL() + `&b=${iconBust}`} alt="" style={{ width: 72, height: 72 }} />
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 72, height: 72 }}>
<ImagePlus size={22} />
</div>
)}
<button className="icon-btn avatar-edit-btn" type="button" aria-label={"Add 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} placeholder={"OwpenGram"} disabled={!loaded} onChange={(event) => setName(event.target.value)} /></label>
<label className="form-field"><span>{"Description"}</span><textarea rows={2} value={description} maxLength={512} disabled={!loaded} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</div>
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
{"Continue"}
</button>
</WizardActions>
{iconModalOpen && (
<ServerIconModal
hasIcon={!!iconExt}
autoReason={WIZARD_REASON}
onClose={() => setIconModalOpen(false)}
onDone={() => { setIconBust((n) => n + 1); void api.serverIdentity().then((info) => setIconExt(info.icon_ext)); }}
/>
)}
</div>
);
}
const NETWORK_FIELDS: { key: string; label: string; hint: string; placeholder: string }[] = [
{
key: "TELESRV_ADVERTISE_IP",
label: "Server public IP or hostname",
hint: "What clients connect to. Fine to leave as 127.0.0.1 for local testing.",
placeholder: "127.0.0.1"
},
{
key: "TELESRV_PUBLIC_BASE_URL",
label: "Public base URL",
hint: "Used for links this server generates -- invites, sticker packs. e.g. https://example.com",
placeholder: "http://127.0.0.1:2401"
},
{
key: "TELESRV_PUBLIC_APP_SCHEME",
label: "Custom app link scheme",
hint: "Must match what your client builds were compiled with.",
placeholder: "owpg"
}
];
function NetworkStep({ onNext }: { onNext: () => void }) {
const [values, setValues] = useState<Record<string, string>>({});
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.serverEnv()
.then((groups) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const group of groups) {
for (const field of group.fields) {
if (NETWORK_FIELDS.some((f) => f.key === field.key)) next[field.key] = field.value;
}
}
setValues(next);
setLoaded(true);
})
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
return () => { cancelled = true; };
}, []);
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/update-server-env", {
command_id: "", reason: WIZARD_REASON, confirm: true, values
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Takes effect once setup finishes below -- that last step restarts the server."}</p>
{error && <Alert>{error}</Alert>}
{NETWORK_FIELDS.map((field) => (
<label key={field.key} className="form-field env-field">
<span>{field.label}</span>
<span className="env-field-desc">{field.hint}</span>
<input
value={values[field.key] ?? ""}
placeholder={field.placeholder}
disabled={!loaded}
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
/>
</label>
))}
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
{"Continue"}
</button>
</WizardActions>
</div>
);
}
const BOT_API_KEY = "TELESRV_BOT_API_ADDR";
const BOT_API_DEFAULT_ADDR = "127.0.0.1:2500";
// An empty TELESRV_BOT_API_ADDR is what disables the gateway: botapi.Start
// returns early on a blank address. A malformed or already-taken one is worth
// catching here rather than server-side, because cmd/telesrv/main.go turns a
// failed botapi.Start into a fatal "start bot api" error -- and the step that
// applies this is the wizard's own restart, so a typo would leave the operator
// staring at a server that never comes back.
function botApiAddrError(addr: string): string {
const value = addr.trim();
if (value === "") return "Enter an address like " + BOT_API_DEFAULT_ADDR + ".";
const colon = value.lastIndexOf(":");
if (colon < 0) return "Include a port, for example " + BOT_API_DEFAULT_ADDR + ".";
const port = Number(value.slice(colon + 1));
if (!Number.isInteger(port) || port < 1 || port > 65535) return "Port must be a whole number between 1 and 65535.";
return "";
}
function BotApiStep({ onNext }: { onNext: () => void }) {
const [enabled, setEnabled] = useState(false);
const [addr, setAddr] = useState("");
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.serverEnv()
.then((groups) => {
if (cancelled) return;
for (const group of groups) {
for (const field of group.fields) {
if (field.key !== BOT_API_KEY) continue;
setEnabled(field.value.trim() !== "");
setAddr(field.value.trim());
}
}
setLoaded(true);
})
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
return () => { cancelled = true; };
}, []);
const addrError = enabled ? botApiAddrError(addr) : "";
async function submit() {
if (addrError) return;
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/update-server-env", {
command_id: "", reason: WIZARD_REASON, confirm: true,
values: { [BOT_API_KEY]: enabled ? addr.trim() : "" }
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">
{"An HTTP gateway that lets bot libraries -- python-telegram-bot, aiogram and friends -- "}
{"talk to this server. Leave it off if you are not running bots; you can turn it on later in Server Settings."}
</p>
{error && <Alert>{error}</Alert>}
<label className="checkline">
<input
type="checkbox"
checked={enabled}
disabled={!loaded}
onChange={(event) => {
const next = event.target.checked;
setEnabled(next);
if (next && addr.trim() === "") setAddr(BOT_API_DEFAULT_ADDR);
}}
/>
{" Enable the Bot API gateway"}
</label>
{enabled && (
<label className="form-field env-field">
<span>{"Listen address"}</span>
<span className="env-field-desc">
{"Keep 127.0.0.1 to accept only local bots; use 0.0.0.0 to expose it. "}
{"The server will refuse to start if this port is already taken."}
</span>
<input
value={addr}
placeholder={BOT_API_DEFAULT_ADDR}
disabled={!loaded}
spellCheck={false}
autoCapitalize="none"
onChange={(event) => setAddr(event.target.value)}
/>
{addrError && <span className="env-field-desc">{addrError}</span>}
</label>
)}
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy || !loaded || addrError !== ""} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
{"Continue"}
</button>
</WizardActions>
</div>
);
}
function AccountStep({ onNext }: { onNext: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const incomplete = username.trim().length < 3 || password.trim() === "";
async function submit() {
if (incomplete) return;
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/create-admin-operator", {
command_id: "", reason: WIZARD_REASON, confirm: true,
username: username.trim(), password, permissions: ["*"], enabled: true
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Replace the generated password with a login of your own."}</p>
{error && <Alert>{error}</Alert>}
<label className="form-field"><span>{"Username"}</span><input autoFocus value={username} spellCheck={false} autoCapitalize="none" placeholder={"letters, digits, dot, dash or underscore"} onChange={(event) => setUsername(event.target.value)} /></label>
<label className="form-field"><span>{"Password"}</span><input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} /></label>
<WizardActions>
<button className="btn" type="button" onClick={onNext}>{"Skip for now"}</button>
<button className="btn primary icon-text" type="button" disabled={busy || incomplete} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <UserPlus size={15} />}
{"Create account & continue"}
</button>
</WizardActions>
</div>
);
}
// DoneStep marks setup complete and restarts, rather than leaving that for
// later -- the network fields two steps back only take effect after a
// restart, and asking the operator to remember to go find Restart in
// Services afterward is exactly the kind of loose end this wizard exists to
// close. useAdminRestartWatcher (shared with Services' own Restart button)
// reloads the page once a genuinely new process answers; passed a
// beforeReload that logs out first here specifically (Services' own Restart
// button doesn't), so the reload lands back on the login form instead of
// straight into the shell still signed in as "owpengram" -- the whole point
// of the Account step just before this one was to have a real login to
// switch to instead. The generated password stops working server-side the
// moment complete-setup runs (see identity.Store.TemporaryPasswordMatches),
// independent of this logout; this just makes sure the browser doesn't
// carry the old session forward and paper over that.
function DoneStep() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const restartWatcher = useAdminRestartWatcher();
async function finish() {
setBusy(true);
setError("");
try {
const completeResult = await api.action("/api/actions/complete-setup", { command_id: "", reason: WIZARD_REASON, confirm: true });
if (completeResult.error) {
setError(completeResult.error);
setBusy(false);
return;
}
const restartResult = await api.action("/api/actions/restart-server", { command_id: "", reason: WIZARD_REASON, confirm: true });
if (restartResult.error) {
setError(restartResult.error);
setBusy(false);
return;
}
void restartWatcher.watch(150000, { beforeReload: async () => { await api.logout(); } });
} catch (err) {
setError(errorMessage(err));
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p>
{"That's the essentials. Finishing restarts the server so the network and Bot API settings from "}
{"the earlier steps take effect. Everything here stays editable from Server Settings and Operators any time."}
</p>
{error && <Alert>{error}</Alert>}
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy} onClick={() => void finish()}>
{busy ? <Loader2 className="spin" size={15} /> : <Rocket size={15} />}
{"Finish setup & restart"}
</button>
</WizardActions>
{restartWatcher.waiting && (
<RestartOverlay timedOut={false} onDismiss={restartWatcher.dismiss} />
)}
{restartWatcher.timedOut && (
<RestartOverlay timedOut={true} onDismiss={restartWatcher.dismiss} />
)}
</div>
);
}

View file

@ -0,0 +1,49 @@
import { ArrowLeft, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { Navigate } from "../routing";
// A full-height "this page is not for you" screen, in place of an alert bar
// bolted to the top of an otherwise empty page frame.
//
// The status code is set large and ghosted behind the message rather than
// spelled out in the text: an operator recognises 403 at a glance, and the
// words are then free to say the useful part -- which right is missing and who
// can grant it.
export function StatusScreen({
code,
icon: Icon,
title,
children,
detail,
navigate
}: {
code: string;
icon: LucideIcon;
title: string;
children: ReactNode;
// The machine-readable thing behind the message: a permission name, a config
// key. Shown in mono, because it is what someone will have to copy.
detail?: string;
navigate?: Navigate;
}) {
return (
<section className="status-screen">
<span className="status-screen-code" aria-hidden="true">{code}</span>
<div className="status-screen-body">
<span className="status-screen-icon"><Icon size={26} /></span>
<h1>{title}</h1>
<p>{children}</p>
{detail && <code className="status-screen-detail">{detail}</code>}
{navigate && (
<button
className="btn primary icon-text"
type="button"
onClick={() => navigate("/")}
>
<ArrowLeft size={15} /> {"Back to overview"}
</button>
)}
</div>
</section>
);
}

View file

@ -72,11 +72,25 @@ export function StatusItem({ label, value, tone }: { label: string; value: strin
); );
} }
export function Metric({ label, value, tone = "neutral", mono = false }: { label: string; value: string; tone?: Tone; mono?: boolean }) { export function Metric({
label,
value,
tone = "neutral",
mono = false,
loading = false
}: {
label: string;
value: string;
tone?: Tone;
mono?: boolean;
loading?: boolean;
}) {
return ( return (
<div className={`metric ${tone}`}> <div className={`metric ${tone}`}>
<span>{label}</span> <span>{label}</span>
<strong className={mono ? "mono" : ""}>{value}</strong> <strong className={mono ? "mono" : ""} aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-text" aria-label="Loading" /> : value}
</strong>
</div> </div>
); );
} }
@ -119,6 +133,26 @@ export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>; return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
} }
// Placeholder rows for a table that has nothing yet *because it is still
// loading* -- distinct from EmptyRow, which asserts the query genuinely
// returned nothing. Showing "No results" during the first fetch reads as a
// wrong answer rather than a pending one.
export function LoadingRow({ colSpan, rows = 3 }: { colSpan: number; rows?: number }) {
return (
<>
{Array.from({ length: rows }, (_, row) => (
<tr key={row} aria-busy="true">
{Array.from({ length: colSpan }, (_unused, cell) => (
<td key={cell}>
<span className="skeleton skeleton-text" aria-label={cell === 0 ? "Loading" : undefined} />
</td>
))}
</tr>
))}
</>
);
}
export function LoadingSurface({ label }: { label: string }) { export function LoadingSurface({ label }: { label: string }) {
return <section className="surface"><div className="loading-line">{label}</div></section>; return <section className="surface"><div className="loading-line">{label}</div></section>;
} }

View file

@ -0,0 +1,35 @@
// Last-known values for screens that are visited repeatedly.
//
// Navigating back to the dashboard used to re-mount it with empty state, so an
// operator watched the same numbers redraw from skeletons every time. The
// screens already refresh themselves; what they lacked was something to show
// while that happens. Reading from here on mount means the page opens on the
// figures it had, and the refresh quietly replaces them.
//
// Kept in module memory rather than localStorage on purpose. This is admin data
// -- account counts, storage figures -- and it has no business outliving the
// tab or sitting on disk. It dies on reload, and clearAdminCache() drops it at
// sign-out so the next operator in the same tab never sees the previous one's
// figures.
const store = new Map<string, unknown>();
export function cacheGet<T>(key: string): T | undefined {
return store.get(key) as T | undefined;
}
export function cacheSet<T>(key: string, value: T): void {
store.set(key, value);
}
export function clearAdminCache(): void {
store.clear();
}
// Keys live here rather than as loose strings at each call site, so a typo
// cannot quietly create a second cache that never hits.
export const cacheKeys = {
dashboard: "dashboard",
storageStats: "storage.stats",
storageAccounts: "storage.accounts"
} as const;

View file

@ -193,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
} }
return ids; 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

@ -0,0 +1,423 @@
import { KeyRound, Lock, RefreshCw, ShieldCheck, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, LoadingRow, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { groupPermissions, permissionAll, permissionHint, permissionTitle } from "../permissions";
import type { AdminConsoleUser, AdminConsoleSystemOperator } from "../types";
// The operator-accounts screen. The table only reports; every change happens in
// a modal and goes through the panel's usual reason + dry-run + confirm flow,
// because handing somebody the run of the console deserves the same "here is
// what this will do" step as freezing an account.
//
// Everything here is additionally enforced server-side by admins.manage --
// hiding the section is a convenience, not the boundary.
export function AdminUsersPage() {
const [rows, setRows] = useState<AdminConsoleUser[]>([]);
const [system, setSystem] = useState<AdminConsoleSystemOperator | null>(null);
const [available, setAvailable] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const [editing, setEditing] = useState<AdminConsoleUser | null>(null);
const [resetting, setResetting] = useState<AdminConsoleUser | null>(null);
const [creating, setCreating] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const result = await api.adminUsers();
setRows(result.rows ?? []);
setSystem(result.system ?? null);
setAvailable(result.available_permissions ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
setLoaded(true);
}
}
useEffect(() => {
void load();
}, []);
return (
<PageFrame eyebrow={"ACCESS / OPERATORS"} title={"Admin operators"}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="toolbar">
<button className="btn primary icon-text" type="button" onClick={() => setCreating(true)}>
<UserPlus size={15} /> {"New operator"}
</button>
<button className="btn icon-text" type="button" onClick={() => void load()} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</div>
</QueryPanel>
<SectionHead title={"Operators"} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Can do"}</th>
<th>{"Status"}</th>
<th>{"Last login"}</th>
<th></th>
</tr>
</thead>
<tbody>
{/* The built-in operator first: it has the most rights and no
database row, so a list that started with the named accounts
would put the most powerful login last, or nowhere. */}
{system && (
<tr>
<td className="mono">
{system.username} <span className="pill">{"built-in"}</span>
</td>
<td><PermissionChips permissions={system.permissions} /></td>
<td><span className="pill good">{"Enabled"}</span></td>
<td className="mono">{"—"}</td>
<td>
<span className="muted icon-text">
<Lock size={13} /> {"Set in the server environment"}
</span>
</td>
</tr>
)}
{rows.map((row) => (
<tr key={row.id}>
<td className="mono">{row.username}</td>
<td><PermissionChips permissions={row.permissions} /></td>
<td>
{row.enabled
? <span className="pill good">{"Enabled"}</span>
: <span className="pill">{"Disabled"}</span>}
</td>
<td className="mono">{row.last_login_at ? new Date(row.last_login_at).toLocaleString() : "—"}</td>
<td>
<button className="btn icon-text" type="button" onClick={() => setEditing(row)}>
<ShieldCheck size={14} /> {"Access"}
</button>
<button className="btn icon-text" type="button" onClick={() => setResetting(row)}>
<KeyRound size={14} /> {"Password"}
</button>
</td>
</tr>
))}
{rows.length === 0 && !system &&
(busy || !loaded ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
</tbody>
</table>
</div>
{creating && (
<OperatorModal
title={"New operator"}
available={available}
onClose={() => setCreating(false)}
onDone={() => { setCreating(false); void load(); }}
/>
)}
{editing && (
<OperatorModal
title={`Access for ${editing.username}`}
available={available}
existing={editing}
onClose={() => setEditing(null)}
onDone={() => { setEditing(null); void load(); }}
/>
)}
{resetting && (
<PasswordModal
operator={resetting}
onClose={() => setResetting(null)}
onDone={() => { setResetting(null); void load(); }}
/>
)}
</PageFrame>
);
}
function PermissionChips({ permissions }: { permissions: string[] }) {
if (permissions.length === 0) {
return <span className="muted">{"nothing yet"}</span>;
}
return (
<span className="chip-row">
{permissions.map((p) => (
<span className="chip" key={p} title={p}>{permissionTitle(p)}</span>
))}
</span>
);
}
// PermissionPicker lists the rights by what they let someone do, split into the
// section of the console each governs -- twenty-six checkboxes in one run is a
// wall nobody reads, and the grouping is what makes "what can this person
// actually touch" answerable at a glance.
//
// The raw permission string stays as each row's tooltip, so the screen never
// hides what is actually being stored.
//
// "*" gets its own row rather than a box in the grid below, because
// assignablePermissions() deliberately leaves it out of the assignable list
// (cmd/telesrv-admin/security.go) -- without this row an operator holding the
// wildcard, like the one the first-run wizard creates, renders as every box
// unticked while Has() answers true for everything, and there is no way to take
// it away again. While it is on the grid is disabled: normalisePermissions
// collapses "*" plus anything back to just "*", so ticking a box there would be
// a no-op the screen would otherwise show as a change.
function PermissionPicker({
available,
selected,
onToggle,
onToggleGroup,
onToggleAll
}: {
available: string[];
selected: string[];
onToggle: (permission: string, on: boolean) => void;
onToggleGroup: (permissions: string[], on: boolean) => void;
onToggleAll: (on: boolean) => void;
}) {
const full = selected.includes(permissionAll);
return (
<div className="permission-groups">
<section className="permission-group">
<div className="permission-grid">
<label className="permission-item" title={permissionAll}>
<input type="checkbox" checked={full} onChange={(event) => onToggleAll(event.target.checked)} />
<span className="permission-copy">
<strong>{"Full access"}</strong>
<small>{"Every right below, including ones added in future updates. Turn off to pick rights individually."}</small>
</span>
</label>
</div>
</section>
{groupPermissions(available).map((group) => {
const all = group.permissions.every((p) => selected.includes(p));
return (
<section className="permission-group" key={group.title}>
<div className="permission-group-head">
<div>
<strong>{group.title}</strong>
<small>{group.hint}</small>
</div>
<button
className="btn compact"
type="button"
disabled={full}
onClick={() => onToggleGroup(group.permissions, !all)}
>
{all ? "Clear" : "Select all"}
</button>
</div>
<div className="permission-grid">
{group.permissions.map((permission) => (
<label className="permission-item" key={permission} title={permission}>
<input
type="checkbox"
checked={full || selected.includes(permission)}
disabled={full}
onChange={(event) => onToggle(permission, event.target.checked)}
/>
<span className="permission-copy">
<strong>{permissionTitle(permission)}</strong>
<small>{permissionHint(permission)}</small>
</span>
</label>
))}
</div>
</section>
);
})}
</div>
);
}
// OperatorModal creates a new operator, or edits an existing one's access. The
// same shape either way: the only difference is whether a username and password
// are being chosen.
//
// Laid out as head / scrolling body / action bar like every other command modal
// in the panel, so a long permission list scrolls inside the dialog instead of
// pushing its own confirm button off the screen.
function OperatorModal({
title,
available,
existing,
onClose,
onDone
}: {
title: string;
available: string[];
existing?: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [username, setUsername] = useState(existing?.username ?? "");
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState<string[]>(existing?.permissions ?? []);
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
const isEdit = Boolean(existing);
// Only the shape the server insists on: a username it will accept, and a
// password that is actually present. Length is the operator's business.
const incomplete = isEdit
? false
: username.trim().length < 3 || password.trim() === "";
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{title}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
{!isEdit && (
<div className="operator-identity">
<label className="duration-field">
<span>{"Username"}</span>
<input
autoFocus
value={username}
spellCheck={false}
autoCapitalize="none"
placeholder={"letters, digits, dot, dash or underscore"}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="duration-field">
<span>{"Password"}</span>
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</div>
)}
<PermissionPicker
available={available}
selected={permissions}
onToggle={(permission, on) =>
setPermissions((current) =>
on ? [...current, permission] : current.filter((p) => p !== permission)
)
}
onToggleGroup={(group, on) =>
setPermissions((current) =>
on
? [...current, ...group.filter((p) => !current.includes(p))]
: current.filter((p) => !group.includes(p))
)
}
onToggleAll={(on) =>
setPermissions((current) =>
on ? [permissionAll] : current.filter((p) => p !== permissionAll)
)
}
/>
<label className="permission-item standalone">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
<span className="permission-copy">
<strong>{"Account is enabled"}</strong>
<small>{"A disabled operator cannot sign in"}</small>
</span>
</label>
{isEdit && (
<Alert>{"The new access applies from this operator's next request. They stay signed in."}</Alert>
)}
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={isEdit ? "Save access" : "Create operator"}
path={isEdit ? "/api/actions/set-admin-operator-access" : "/api/actions/create-admin-operator"}
tone="primary"
disabled={incomplete}
icon={isEdit ? <ShieldCheck size={15} /> : <UserPlus size={15} />}
payload={() =>
isEdit
? { id: existing?.id, permissions, enabled }
: { username: username.trim(), password, permissions, enabled }
}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}
function PasswordModal({
operator,
onClose,
onDone
}: {
operator: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [password, setPassword] = useState("");
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal narrow" role="dialog" aria-modal="true" aria-label={"Set password"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{`Password for ${operator.username}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className="duration-field">
<span>{"New password"}</span>
<input
autoFocus
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<Alert>{"Changing the password signs this operator out of any session they already have."}</Alert>
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={"Set password"}
path={"/api/actions/set-admin-operator-password"}
tone="primary"
disabled={password.trim() === ""}
icon={<KeyRound size={15} />}
payload={() => ({ id: operator.id, password })}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}

View file

@ -68,7 +68,7 @@ export function BroadcastsPage() {
}, []); }, []);
const rows = data?.rows ?? []; const rows = data?.rows ?? [];
const inFlight = rows.filter((row) => row.SentCount + row.FailedCount < row.TotalCount).length; const inFlight = rows.filter((row) => !row.EnumerationDone || row.SentCount + row.FailedCount < row.TargetCount).length;
const canGoPrev = history.length > 0 && !busy; const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy; const canGoNext = Boolean(data?.has_more) && !busy;
@ -110,7 +110,7 @@ export function BroadcastsPage() {
<tbody> <tbody>
{rows.map((row) => { {rows.map((row) => {
const delivered = row.SentCount + row.FailedCount; const delivered = row.SentCount + row.FailedCount;
const done = row.TotalCount > 0 && delivered >= row.TotalCount; const done = row.EnumerationDone && row.TargetCount > 0 && delivered >= row.TargetCount;
return ( return (
<tr key={row.ID}> <tr key={row.ID}>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
@ -118,7 +118,7 @@ export function BroadcastsPage() {
<td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td> <td>{row.TargetMode === "all" ? <Badge tone="warn">{"All users"}</Badge> : <Badge>{"Selected"}</Badge>}</td>
<td>{row.SentCount}</td> <td>{row.SentCount}</td>
<td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td> <td>{row.FailedCount > 0 ? <Badge tone="danger">{row.FailedCount}</Badge> : row.FailedCount}</td>
<td>{row.TotalCount}</td> <td>{row.TargetCount}</td>
<td>{row.CreatedBy || "-"}</td> <td>{row.CreatedBy || "-"}</td>
<td> <td>
{formatDate(row.CreatedAt)} {formatDate(row.CreatedAt)}

View file

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

View file

@ -17,13 +17,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import { formatBytes, formatQuantity } from "../lib/format"; import { formatBytes, formatQuantity } from "../lib/format";
import type { DashboardResponse } from "../types"; import type { DashboardResponse } from "../types";
export function Dashboard({ navigate }: { navigate: Navigate }) { export function Dashboard({ navigate }: { navigate: Navigate }) {
const [data, setData] = useState<DashboardResponse | null>(null); // Seeded from the last values this session saw, so coming back to the
// dashboard opens on the numbers instead of on a grid of skeletons. The
// 15s refresh below still runs and replaces them.
const [data, setData] = useState<DashboardResponse | null>(
() => cacheGet<DashboardResponse>(cacheKeys.dashboard) ?? null
);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@ -31,6 +37,7 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
async function load() { async function load() {
try { try {
const res = await api.dashboard(); const res = await api.dashboard();
cacheSet(cacheKeys.dashboard, res);
if (!cancelled) setData(res); if (!cancelled) setData(res);
} catch (err) { } catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard"); if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load dashboard");
@ -58,7 +65,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile <StatTile
icon={<Flag />} icon={<Flag />}
label="Pending reports" label="Pending reports"
value={counts ? formatQuantity(String(counts.PendingReports)) : "…"} value={counts ? formatQuantity(String(counts.PendingReports)) : ""}
loading={!counts && !error}
tone={counts && counts.PendingReports > 0 ? "warn" : "good"} tone={counts && counts.PendingReports > 0 ? "warn" : "good"}
href="/moderation" href="/moderation"
navigate={navigate} navigate={navigate}
@ -66,7 +74,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile <StatTile
icon={<BadgeCheck />} icon={<BadgeCheck />}
label="Verification requests" label="Verification requests"
value={counts ? formatQuantity(String(counts.PendingVerifications)) : "…"} value={counts ? formatQuantity(String(counts.PendingVerifications)) : ""}
loading={!counts && !error}
tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"} tone={counts && counts.PendingVerifications > 0 ? "warn" : "good"}
href="/verification" href="/verification"
navigate={navigate} navigate={navigate}
@ -74,27 +83,44 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
</Section> </Section>
<Section title="People &amp; chats"> <Section title="People &amp; chats">
<StatTile icon={<Users />} label="Users" value={counts ? formatQuantity(String(counts.Users)) : "…"} href="/accounts" navigate={navigate} /> <StatTile
icon={<Users />}
label="Users"
value={counts ? formatQuantity(String(counts.Users)) : ""}
loading={!counts && !error}
href="/accounts"
navigate={navigate}
/>
<StatTile <StatTile
icon={<Activity />} icon={<Activity />}
label="Online now" label="Online now"
value={counts ? formatQuantity(String(counts.OnlineUsers)) : "…"} value={counts ? formatQuantity(String(counts.OnlineUsers)) : ""}
loading={!counts && !error}
sub="last 5 min" sub="last 5 min"
href="/accounts" href="/accounts"
navigate={navigate} navigate={navigate}
/> />
<StatTile icon={<Bot />} label="Bots" value={counts ? formatQuantity(String(counts.Bots)) : "…"} href="/bots" navigate={navigate} /> <StatTile
icon={<Bot />}
label="Bots"
value={counts ? formatQuantity(String(counts.Bots)) : ""}
loading={!counts && !error}
href="/bots"
navigate={navigate}
/>
<StatTile <StatTile
icon={<Radio />} icon={<Radio />}
label="Channels" label="Channels"
value={counts ? formatQuantity(String(counts.BroadcastChannels)) : "…"} value={counts ? formatQuantity(String(counts.BroadcastChannels)) : ""}
loading={!counts && !error}
href="/channels" href="/channels"
navigate={navigate} navigate={navigate}
/> />
<StatTile <StatTile
icon={<UsersRound />} icon={<UsersRound />}
label="Supergroups" label="Supergroups"
value={counts ? formatQuantity(String(counts.Supergroups)) : "…"} value={counts ? formatQuantity(String(counts.Supergroups)) : ""}
loading={!counts && !error}
href="/channels" href="/channels"
navigate={navigate} navigate={navigate}
/> />
@ -104,21 +130,24 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile <StatTile
icon={<Sticker />} icon={<Sticker />}
label="Sticker packs" label="Sticker packs"
value={counts ? formatQuantity(String(counts.StickerSets)) : "…"} value={counts ? formatQuantity(String(counts.StickerSets)) : ""}
loading={!counts && !error}
href="/stickers" href="/stickers"
navigate={navigate} navigate={navigate}
/> />
<StatTile <StatTile
icon={<Smile />} icon={<Smile />}
label="Emoji packs" label="Emoji packs"
value={counts ? formatQuantity(String(counts.EmojiSets)) : "…"} value={counts ? formatQuantity(String(counts.EmojiSets)) : ""}
loading={!counts && !error}
href="/emoji" href="/emoji"
navigate={navigate} navigate={navigate}
/> />
<StatTile <StatTile
icon={<Film />} icon={<Film />}
label="GIFs" label="GIFs"
value={counts ? formatQuantity(String(counts.Gifs)) : "…"} value={counts ? formatQuantity(String(counts.Gifs)) : ""}
loading={!counts && !error}
sub="saved by users" sub="saved by users"
href="/gif-catalog" href="/gif-catalog"
navigate={navigate} navigate={navigate}
@ -126,7 +155,8 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
<StatTile <StatTile
icon={<Database />} icon={<Database />}
label="Media storage used" label="Media storage used"
value={storage ? formatBytes(storage.PhysicalBytes) : "…"} value={storage ? formatBytes(storage.PhysicalBytes) : ""}
loading={!storage && !error}
sub={storage ? `${storage.BackendKind} backend` : undefined} sub={storage ? `${storage.BackendKind} backend` : undefined}
href="/storage" href="/storage"
navigate={navigate} navigate={navigate}
@ -138,25 +168,32 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
icon={<Cpu />} icon={<Cpu />}
label="CPU load" label="CPU load"
percent={host?.Ready ? host.CPUPercent : undefined} percent={host?.Ready ? host.CPUPercent : undefined}
valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : "…"} valueText={host?.Ready ? `${host.CPUPercent.toFixed(0)}%` : ""}
loading={!host?.Ready && !error}
/> />
<UsageTile <UsageTile
icon={<MemoryStick />} icon={<MemoryStick />}
label="RAM used" label="RAM used"
percent={host?.Ready && host.MemTotalBytes > 0 ? (host.MemUsedBytes / host.MemTotalBytes) * 100 : undefined} percent={host?.Ready && host.MemTotalBytes > 0 ? (host.MemUsedBytes / host.MemTotalBytes) * 100 : undefined}
valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : "…"} valueText={host?.Ready ? formatBytes(String(host.MemUsedBytes)) : ""}
loading={!host?.Ready && !error}
sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined} sub={host?.Ready ? `of ${formatBytes(String(host.MemTotalBytes))}` : undefined}
/> />
<UsageTile <UsageTile
icon={<HardDrive />} icon={<HardDrive />}
label="Disk free" label="Disk free"
percent={ percent={
host?.Ready && host.DiskTotalBytes > 0 host?.Ready && host.DiskReady && host.DiskTotalBytes > 0
? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100 ? ((host.DiskTotalBytes - host.DiskFreeBytes) / host.DiskTotalBytes) * 100
: undefined : undefined
} }
valueText={host?.Ready ? formatBytes(String(host.DiskFreeBytes)) : "…"} // Skeleton only until the first host sample lands. After that a
sub={host?.Ready ? `of ${formatBytes(String(host.DiskTotalBytes))}` : undefined} // missing disk reading is a real state, not a pending one -- it can
// stay that way indefinitely, and a shimmer would promise a value
// that is never coming.
valueText={host?.Ready && host.DiskReady ? formatBytes(String(host.DiskFreeBytes)) : "—"}
loading={!host?.Ready && !error}
sub={host?.Ready && host.DiskReady ? `of ${formatBytes(String(host.DiskTotalBytes))}` : "no reading yet"}
warnAbove={85} warnAbove={85}
/> />
</Section> </Section>
@ -185,7 +222,8 @@ function StatTile({
sub, sub,
tone = "neutral", tone = "neutral",
href, href,
navigate navigate,
loading = false
}: { }: {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
@ -194,6 +232,7 @@ function StatTile({
tone?: Tone; tone?: Tone;
href?: string; href?: string;
navigate?: Navigate; navigate?: Navigate;
loading?: boolean;
}) { }) {
const toneClass = tone === "neutral" ? "" : ` ${tone}`; const toneClass = tone === "neutral" ? "" : ` ${tone}`;
const body = ( const body = (
@ -202,7 +241,9 @@ function StatTile({
<span className="stat-tile-icon">{icon}</span> <span className="stat-tile-icon">{icon}</span>
{tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />} {tone === "warn" && <AlertTriangle size={15} className="stat-tile-open" />}
</div> </div>
<div className="stat-tile-value">{value}</div> <div className="stat-tile-value" aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : value}
</div>
<div className="stat-tile-label">{label}</div> <div className="stat-tile-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>} {sub && <div className="stat-tile-sub">{sub}</div>}
</> </>
@ -233,7 +274,8 @@ function UsageTile({
percent, percent,
valueText, valueText,
sub, sub,
warnAbove = 90 warnAbove = 90,
loading = false
}: { }: {
icon: ReactNode; icon: ReactNode;
label: string; label: string;
@ -241,6 +283,7 @@ function UsageTile({
valueText: string; valueText: string;
sub?: string; sub?: string;
warnAbove?: number; warnAbove?: number;
loading?: boolean;
}) { }) {
const clamped = percent === undefined ? 0 : Math.max(0, Math.min(100, percent)); const clamped = percent === undefined ? 0 : Math.max(0, Math.min(100, percent));
const tone: Tone = percent === undefined ? "neutral" : percent >= warnAbove ? "danger" : percent >= warnAbove - 15 ? "warn" : "neutral"; const tone: Tone = percent === undefined ? "neutral" : percent >= warnAbove ? "danger" : percent >= warnAbove - 15 ? "warn" : "neutral";
@ -250,7 +293,9 @@ function UsageTile({
<div className="stat-tile-head"> <div className="stat-tile-head">
<span className="stat-tile-icon">{icon}</span> <span className="stat-tile-icon">{icon}</span>
</div> </div>
<div className="stat-tile-value">{valueText}</div> <div className="stat-tile-value" aria-busy={loading || undefined}>
{loading ? <span className="skeleton skeleton-value" aria-label="Loading" /> : valueText}
</div>
<div className="stat-tile-label">{label}</div> <div className="stat-tile-label">{label}</div>
{sub && <div className="stat-tile-sub">{sub}</div>} {sub && <div className="stat-tile-sub">{sub}</div>}
<div className="stat-tile-bar"> <div className="stat-tile-bar">

View file

@ -1,6 +1,7 @@
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react"; import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { StaticLottie } from "../components/StaticLottie"; import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import type { EmojiListResponse, EmojiRow } from "../types"; import type { EmojiListResponse, EmojiRow } from "../types";
@ -43,7 +44,7 @@ function EmojiCard({ row }: { row: EmojiRow }) {
async function copy() { async function copy() {
try { try {
await navigator.clipboard.writeText(row.DocumentID); await copyToClipboard(row.DocumentID);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1200); setTimeout(() => setCopied(false), 1200);
} catch { } catch {

View file

@ -1,6 +1,7 @@
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { MessageView } from "../components/MessageView";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format"; import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
@ -38,32 +39,38 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
> >
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <MessageView
<div> body={msg.Body}
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div> media={msg.Media}
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div> sender={msg.Post ? `Channel post in ${msg.ChannelID}` : `From ${msg.SenderUserID}`}
</div> meta={`${formatUnix(msg.Date)}${msg.EditDate ? ` · edited ${formatUnix(msg.EditDate)}` : ""}${msg.ViewsCount ? ` · ${msg.ViewsCount} views` : ""}`}
<div className="entity-badges"> badges={
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>} <>
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>} {msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
{msg.Post && <Badge>{"Channel post"}</Badge>} {msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
<Badge>pts {msg.PTS}</Badge> {msg.Post && <Badge>{"Channel post"}</Badge>}
</div> </>
</section> }
/>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={"Message ID"} value={String(msg.ID)} mono /> <Summary label={"Message ID"} value={String(msg.ID)} mono />
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono /> <Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono /> <Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label={"Views"} value={String(msg.ViewsCount)} /> <Summary label={"pts"} value={String(msg.PTS)} mono />
</div> </div>
<section className="section-block"> <details className="raw-details">
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} /> <summary>{"Stored rows (JSON)"}</summary>
<JsonBlock value={detail.MessageJSON} /> <div className="stacked-sections">
</section> <section className="section-block">
<section className="section-block"> <SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} /> <JsonBlock value={detail.MessageJSON} />
<JsonBlock value={detail.ChannelJSON} /> </section>
</section> <section className="section-block">
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
</details>
<section className="section-block"> <section className="section-block">
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} /> <SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
<div className="table-wrap"> <div className="table-wrap">

View file

@ -2,12 +2,12 @@ import { ChevronRight, Search } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker"; import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format"; import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types"; import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) { export function GroupMessagesTab({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null); const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState(""); const [beforeID, setBeforeID] = useState("");
@ -52,7 +52,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const rows = data?.rows ?? []; const rows = data?.rows ?? [];
return ( return (
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}> <>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid single"> <div className="message-selector-grid single">
@ -114,6 +114,6 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
</tbody> </tbody>
</table> </table>
</div> </div>
</PageFrame> </>
); );
} }

View file

@ -1,24 +1,89 @@
import { ArrowLeft, Loader2 } from "lucide-react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { AppBackground } from "../components/AppBackground";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import { ThemeSwitch } from "../theme"; import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types"; import type { AdminSession, PublicBranding } from "../types";
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) { export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
const [username, setUsername] = useState("");
const [secret, setSecret] = useState(""); const [secret, setSecret] = useState("");
// Which field is showing. The password field mounts only on step 1, so
// there is never a submit handler wired to a "confirm" or "log in" button
// that could fire with a still-empty password field -- credentials only
// ever reach api.login once both are on screen and this has advanced.
const [step, setStep] = useState<0 | 1>(0);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
// Whose server this is. Fetched without a session -- the name and icon are
// already public from owpengram-server's client endpoints -- and left null on
// failure so the panel simply keeps its own branding.
const [branding, setBranding] = useState<PublicBranding | null>(null);
const [iconFailed, setIconFailed] = useState(false);
useEffect(() => {
api.publicBranding().then(setBranding).catch(() => undefined);
}, []);
useEffect(() => {
// preventScroll matters here: .login-wizard clips with overflow:hidden and
// is itself a scroll container, so a plain .focus() makes the browser
// scroll it to reveal the field -- fighting the translateX slide and
// leaving a stray scrollLeft behind that then desyncs every later step
// change from what the transform shows.
if (step === 1) {
passwordRef.current?.focus({ preventScroll: true });
} else {
usernameRef.current?.focus({ preventScroll: true });
}
}, [step]);
const serverName = branding?.name?.trim() || "OwpenGram";
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
function goToPassword() {
if (!username.trim()) return;
setError("");
setStep(1);
}
function goToUsername() {
setError("");
setStep(0);
}
// The form has one submit handler regardless of step, because Enter inside
// any of its text inputs fires it -- routing that here means the username
// field's Enter key advances instead of submitting a login with no password.
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (step === 0) {
goToPassword();
return;
}
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
// The login answer carries the permission set and the CSRF token; api.login // api.login remembers the CSRF token and tells us the sign-in worked.
// remembers the token, the session state keeps the rights. const result = await api.login(secret, username);
const result = await api.login(secret); // The session itself is then read from /api/session rather than assembled
onLogin({ actor: result.actor, permissions: result.permissions ?? [] }); // out of the login answer. The login response carries only the actor and
// the permissions, so building a session from it silently dropped the
// build info, the API layers and the third-party-verification flag --
// which is why the sidebar footer was blank until the page was reloaded.
// One endpoint decides what a session is.
try {
onLogin(await api.session());
} catch {
// Signed in, but the follow-up read failed. Falling back to what the
// login answer does carry beats bouncing someone back to a login form
// they have already passed; a reload fills in the rest.
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
}
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
@ -28,44 +93,74 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
return ( return (
<main className="login-page"> <main className="login-page">
<div className="bg-orbs" aria-hidden="true"> <AppBackground />
<div className="bg-orb bg-orb--1" />
<div className="bg-orb bg-orb--2" />
<div className="bg-orb bg-orb--3" />
</div>
<section className="login-panel"> <section className="login-panel">
<div className="login-head"> <div className="login-head">
<div className="brand brand-elevated"> <div className="brand brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span> <span className="brand-mark">
<img src={iconSrc} alt={serverName} onError={() => setIconFailed(true)} />
</span>
<span> <span>
<strong>OwpenGram</strong> <strong>{serverName}</strong>
<small>{"Admin Console"}</small> <small>{"Admin Console"}</small>
</span> </span>
</div> </div>
<div className="login-head-actions"> <div className="login-head-actions">
<ThemeSwitch /> <ThemeSwitch />
<span className="login-chip">{"Local access"}</span>
</div> </div>
</div> </div>
<div className="login-copy">
<h1>{"Operations Admin"}</h1>
<p>{"Enter credentials to open the console."}</p>
</div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}> <form className="form-stack" onSubmit={submit}>
<label> <div className="login-wizard">
<span>{"Admin password or token"}</span> <div className="login-wizard-track" style={{ transform: `translateX(-${step * 100}%)` }}>
<input <div className="login-wizard-step" aria-hidden={step !== 0}>
autoFocus <label>
type="password" <span>{"Username"}</span>
value={secret} <input
autoComplete="current-password" ref={usernameRef}
onChange={(event) => setSecret(event.target.value)} type="text"
/> value={username}
</label> autoComplete="username"
<button className="btn primary full" type="submit" disabled={busy}> spellCheck={false}
{busy ? "Logging in" : "Log in"} autoCapitalize="none"
</button> placeholder={"login"}
tabIndex={step === 0 ? undefined : -1}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
</div>
<div className="login-wizard-step" aria-hidden={step !== 1}>
<label>
<span>{"Password"}</span>
<input
ref={passwordRef}
type="password"
value={secret}
autoComplete="current-password"
placeholder={"password"}
tabIndex={step === 1 ? undefined : -1}
onChange={(event) => setSecret(event.target.value)}
/>
</label>
</div>
</div>
</div>
{step === 0 ? (
<button className="btn primary full" type="submit" disabled={!username.trim()}>
{"Next"}
</button>
) : (
<div className="login-wizard-actions">
<button className="btn icon-text" type="button" onClick={goToUsername}>
<ArrowLeft size={15} />
{"Back"}
</button>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : null}
{busy ? "Logging in" : "Log in"}
</button>
</div>
)}
</form> </form>
</section> </section>
</main> </main>

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { MessageView } from "../components/MessageView";
import { formatDate, formatUnix } from "../lib/format"; import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { MessageDetail } from "../types"; import type { MessageDetail } from "../types";
@ -41,37 +42,46 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
<SplitLayout <SplitLayout
main={ main={
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <MessageView
<div> body={msg.Body}
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div> media={msg.Media}
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div> sender={`From ${msg.FromUserID}`}
</div> meta={`${msg.Outgoing ? "Sent to" : "Received from"} ${msg.PeerID} · ${formatUnix(msg.Date)}`}
<div className="entity-badges"> badges={
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>} <>
<Badge>pts {msg.PTS}</Badge> {msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge> <Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
</div> </>
</section> }
/>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono /> <Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono /> <Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono /> <Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
<Summary label={"Time"} value={formatUnix(msg.Date)} /> <Summary label={"pts"} value={String(msg.PTS)} mono />
</div>
<section className="section-block">
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div> </div>
{/* The stored rows stay reachable, but folded: they answer "why is
this message in this state", which is a rarer question than
"what does it say". */}
<details className="raw-details">
<summary>{"Stored rows (JSON)"}</summary>
<div className="stacked-sections">
<section className="section-block">
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
</div>
</details>
<section className="section-block"> <section className="section-block">
<SectionHead title={"Update Events"} text={"durable user_update_events"} /> <SectionHead title={"Update Events"} text={"durable user_update_events"} />
<div className="table-wrap"> <div className="table-wrap">

View file

@ -7,8 +7,9 @@ import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../compon
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format"; import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types"; import type { AccountRow, MessageListResponse } from "../types";
import { GroupMessagesTab } from "./GroupMessagesPage";
export function MessagesPage({ navigate }: { navigate: Navigate }) { export function PrivateMessagesTab({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null); const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null); const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
@ -65,7 +66,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
} }
return ( return (
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}> <>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid"> <div className="message-selector-grid">
@ -152,6 +153,42 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
</tbody> </tbody>
</table> </table>
</div> </div>
</>
);
}
// The two message stores are one screen with two tabs rather than two sidebar
// entries: they are the same job ("look at what was said") over different peer
// kinds, and a nested menu made that look like two unrelated sections.
export function MessagesPage({ navigate, tab, onTab }: {
navigate: Navigate;
tab: "private" | "groups";
onTab: (tab: "private" | "groups") => void;
}) {
return (
<PageFrame title={"Messages"} eyebrow={"Message boxes and channel history"}>
<div className="tab-bar" role="tablist" aria-label={"Message sections"}>
<button
className={`tab-btn ${tab === "private" ? "active" : ""}`}
type="button"
role="tab"
aria-selected={tab === "private"}
onClick={() => onTab("private")}
>
{"Private"}
</button>
<button
className={`tab-btn ${tab === "groups" ? "active" : ""}`}
type="button"
role="tab"
aria-selected={tab === "groups"}
onClick={() => onTab("groups")}
>
{"Groups and channels"}
</button>
</div>
{tab === "private" ? <PrivateMessagesTab navigate={navigate} /> : <GroupMessagesTab navigate={navigate} />}
</PageFrame> </PageFrame>
); );
} }

View file

@ -0,0 +1,176 @@
import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { ReservedUsernameRow } from "../types";
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
// taken as an editable username by any peer and cannot be minted as a
// collectible. No owner, no price, no "bought on Fragment" badge - that is the
// collectible tab's job.
export function ReservedUsernamesPage() {
const [q, setQ] = useState("");
const [reserveOpen, setReserveOpen] = useState(false);
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function load() {
setLoading(true);
setError("");
const params = new URLSearchParams({ limit: "200" });
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
try {
const result = await api.reservedUsernames(params);
setRows(result.reserved ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
return (
<PageFrame
title={"Reserved usernames"}
eyebrow={"Usernames / Blocklist"}
actions={
<>
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
<Plus size={15} /> {"Reserve username"}
</button>
<button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Reserved names"} value={String(rows.length)} />
</div>
<QueryPanel>
<form
className="toolbar"
onSubmit={(event) => {
event.preventDefault();
void load();
}}
>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
</label>
<button className="btn primary icon-text" type="submit" disabled={loading}>
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Reason"}</th>
<th>{"Reserved by"}</th>
<th>{"Reserved (UTC)"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.username}>
<td><strong>{`@${row.username}`}</strong></td>
<td>{row.reason || "-"}</td>
<td>{row.actor || "-"}</td>
<td>{formatUnix(row.created_at) || "-"}</td>
<td>
<ActionButton
compact
label={"Unreserve"}
icon={<Trash2 size={13} />}
tone="danger"
path="/api/actions/unreserve-username"
payload={() => ({ username: row.username })}
onDone={() => void load()}
/>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={5} />}
</tbody>
</table>
</div>
{reserveOpen && (
<ReserveUsernameModal
onClose={() => setReserveOpen(false)}
onDone={() => {
setReserveOpen(false);
void load();
}}
/>
)}
</PageFrame>
);
}
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
// standard reason / dry-run / confirm flow - the same as every other admin
// action. The name is read fresh from state on each ActionButton render.
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState("");
const clean = username.trim().replace(/^@/, "");
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Reserve a username"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Usernames"}</div>
<h2>{"Reserve a username"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}>
<X size={15} />
</button>
</div>
<div className="command-body">
<label className="form-field">
<span>{"Username"}</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="support"
autoFocus
/>
</label>
<p className="bot-create-note">
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
</p>
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<ActionButton
disabled={clean.length < 5}
label={"Reserve username"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/reserve-username"
payload={() => ({ username: clean })}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body,
);
}

View file

@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { type Navigate, type RouteState } from "../routing"; import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage"; import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage"; import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage"; import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage"; import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage"; import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage"; import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage"; import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage"; import { BotDetailPage } from "./BotDetailPage";
@ -11,11 +13,12 @@ import { BotsPage } from "./BotsPage";
import { BroadcastsPage } from "./BroadcastsPage"; import { BroadcastsPage } from "./BroadcastsPage";
import { Dashboard } from "./Dashboard"; import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage"; import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage"; import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage"; import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage"; import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage"; import { GifCatalogPage } from "./GifCatalogPage";
import { AdminUsersPage } from "./AdminUsersPage";
import { ServerSettingsPage } from "./ServerSettingsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage"; import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage"; import { ModerationCasesPage } from "./ModerationCasesPage";
import { StoragePage } from "./StoragePage"; import { StoragePage } from "./StoragePage";
@ -26,11 +29,30 @@ import { VerificationPage } from "./VerificationPage";
import { import {
PermissionGate, PermissionGate,
ThirdPartyVerificationHiddenGate, ThirdPartyVerificationHiddenGate,
permissionAccountsRead,
permissionAdminsManage,
permissionBotVerificationReview, permissionBotVerificationReview,
permissionBotsRead,
permissionBroadcastsRead,
permissionChannelsRead,
permissionContentRead,
permissionDashboardRead,
permissionMessagesRead,
permissionModerationReview,
permissionServerManage,
permissionStorageRead,
permissionUsernamesRead,
permissionVerificationReview permissionVerificationReview
} from "../permissions"; } from "../permissions";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) { export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
// Every section is wrapped in the right it needs. Without this the page
// rendered, fired its request, and showed the backend's "permission X is
// required" as a red bar over an empty table -- an error where a refusal
// belongs. Gating here means the request is never made either.
const gate = (permission: string, node: ReactNode) => (
<PermissionGate navigate={navigate} permission={permission}>{node}</PermissionGate>
);
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1]; const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1]; const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1]; const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
@ -43,8 +65,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1]; const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
if (botVerificationRequestID) { if (botVerificationRequestID) {
return ( return (
<ThirdPartyVerificationHiddenGate> <ThirdPartyVerificationHiddenGate navigate={navigate}>
<PermissionGate permission={permissionBotVerificationReview}> <PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} /> <BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
</PermissionGate> </PermissionGate>
</ThirdPartyVerificationHiddenGate> </ThirdPartyVerificationHiddenGate>
@ -52,8 +74,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
} }
if (route.path === "/bot-verification") { if (route.path === "/bot-verification") {
return ( return (
<ThirdPartyVerificationHiddenGate> <ThirdPartyVerificationHiddenGate navigate={navigate}>
<PermissionGate permission={permissionBotVerificationReview}> <PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
<BotVerificationPage navigate={navigate} /> <BotVerificationPage navigate={navigate} />
</PermissionGate> </PermissionGate>
</ThirdPartyVerificationHiddenGate> </ThirdPartyVerificationHiddenGate>
@ -64,89 +86,111 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
// itself instead of rendering an empty queue. // itself instead of rendering an empty queue.
if (verificationID) { if (verificationID) {
return ( return (
<PermissionGate permission={permissionVerificationReview}> <PermissionGate navigate={navigate} permission={permissionVerificationReview}>
<VerificationDetailPage id={verificationID} navigate={navigate} /> <VerificationDetailPage id={verificationID} navigate={navigate} />
</PermissionGate> </PermissionGate>
); );
} }
if (route.path === "/verification") { if (route.path === "/verification") {
return ( return (
<PermissionGate permission={permissionVerificationReview}> <PermissionGate navigate={navigate} permission={permissionVerificationReview}>
<VerificationPage navigate={navigate} /> <VerificationPage navigate={navigate} />
</PermissionGate> </PermissionGate>
); );
} }
if (collectibleUsernameID) { if (collectibleUsernameID) {
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />; return gate(permissionUsernamesRead, <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />);
} }
if (route.path === "/collectible-usernames") { if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />; return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
}
if (route.path === "/reserved-usernames") {
return <ReservedUsernamesPage />;
} }
if (route.path === "/storage") { if (route.path === "/storage") {
return <StoragePage navigate={navigate} />; return gate(permissionStorageRead, <StoragePage navigate={navigate} />);
} }
if (accountID) { if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />; return gate(permissionAccountsRead, <AccountDetailPage id={Number(accountID)} navigate={navigate} />);
} }
if (channelID) { if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />; return gate(permissionChannelsRead, <ChannelDetailPage id={Number(channelID)} navigate={navigate} />);
} }
if (botID) { if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />; return gate(permissionBotsRead, <BotDetailPage id={Number(botID)} navigate={navigate} />);
} }
if (moderationCaseID) { if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />; return gate(permissionModerationReview, <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />);
} }
if (route.path === "/accounts/shared-devices") { if (route.path === "/accounts/shared-devices") {
return <SharedDevicesPage navigate={navigate} />; return gate(permissionAccountsRead, <SharedDevicesPage navigate={navigate} />);
} }
if (route.path === "/accounts") { if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />; return gate(permissionAccountsRead, <AccountsPage navigate={navigate} />);
} }
if (route.path === "/channels") { if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />; return gate(permissionChannelsRead, <ChannelsPage navigate={navigate} />);
} }
if (route.path === "/bots") { if (route.path === "/bots") {
return <BotsPage navigate={navigate} />; return gate(permissionBotsRead, <BotsPage navigate={navigate} />);
} }
if (route.path === "/moderation") { if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />; return gate(permissionModerationReview, <ModerationCasesPage navigate={navigate} />);
} }
if (route.path === "/broadcasts") { if (route.path === "/broadcasts") {
return <BroadcastsPage />; return gate(permissionBroadcastsRead, <BroadcastsPage />);
} }
if (route.path === "/emoji") { if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />; return gate(permissionContentRead, <StickerSetsPage kind="emoji" />);
} }
if (route.path === "/stickers") { if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />; return gate(permissionContentRead, <StickerSetsPage kind="stickers" />);
} }
if (route.path === "/gif-catalog") { if (route.path === "/gif-catalog") {
return <GifCatalogPage />; return gate(permissionContentRead, <GifCatalogPage />);
}
if (route.path === "/admin-users") {
return (
<PermissionGate navigate={navigate} permission={permissionAdminsManage}>
<AdminUsersPage />
</PermissionGate>
);
}
if (route.path === "/server-settings") {
return (
<PermissionGate navigate={navigate} permission={permissionServerManage}>
<ServerSettingsPage />
</PermissionGate>
);
} }
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") { if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return ( return gate(permissionMessagesRead, (
<MessageDetailPage <MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")} ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")} msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate} navigate={navigate}
/> />
); ));
} }
if (route.path === "/messages/groups/detail") { if (route.path === "/messages/groups/detail") {
return ( return gate(permissionMessagesRead, (
<GroupMessageDetailPage <GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")} channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")} msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate} navigate={navigate}
/> />
); ));
} }
if (route.path === "/messages/groups") { // Both tabs keep their own path so a link to one still opens on it -- the
return <GroupMessagesPage navigate={navigate} />; // tab is a view of /messages, not a hidden bit of component state.
if (route.path === "/messages" || route.path === "/messages/private" || route.path === "/messages/groups") {
return gate(permissionMessagesRead, (
<MessagesPage
navigate={navigate}
tab={route.path === "/messages/groups" ? "groups" : "private"}
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/private")}
/>
));
} }
if (route.path === "/messages" || route.path === "/messages/private") { return gate(permissionDashboardRead, <Dashboard navigate={navigate} />);
return <MessagesPage navigate={navigate} />;
}
return <Dashboard navigate={navigate} />;
} }

View file

@ -0,0 +1,818 @@
import { ChevronDown, CircleCheck, CircleOff, CircleX, Database, Download, HardDrive, ImageOff, ImagePlus, Layers, Loader2, RefreshCw, Server, ShieldCheck, 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, PageFrame, SectionHead } from "../components/ui";
import type { DockerService, 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
// live process/Docker status + Restart/Update. See
// cmd/telesrv-admin/serversettings.go for the backend.
//
// Split into two tabs: "Settings" (identity + .env, rarely touched, no live
// state) and "Services" (live process/container status + restart/update,
// the operational side someone actually watches while things are moving).
export function ServerSettingsPage() {
const [tab, setTab] = useState<"settings" | "services">("settings");
return (
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and live process/service control"}>
<div className="tab-bar" role="tablist" aria-label={"Server Settings sections"}>
<button className={`tab-btn ${tab === "settings" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "settings"} onClick={() => setTab("settings")}>
{"Settings"}
</button>
<button className={`tab-btn ${tab === "services" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "services"} onClick={() => setTab("services")}>
{"Services"}
</button>
</div>
{tab === "settings" ? (
<div className="stacked-sections">
<IdentitySection />
<LoginNotificationsSection />
<EnvSection />
</div>
) : (
<div className="stacked-sections">
<ServicesTab />
</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"} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading identity..."} />
) : (
<div className="card-body identity-card">
<div className="identity-layout">
<div className="avatar-edit-slot">
{identity.icon_ext && !iconFailed ? (
<img
className="avatar-photo-img"
src={api.serverIconURL() + `&b=${iconBust}`}
alt=""
style={{ width: 88, height: 88 }}
onError={() => setIconFailed(true)}
/>
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 88, height: 88 }}>
<ImageOff size={26} />
</div>
)}
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change server icon"}
title={"Change server icon"}
onClick={() => setIconModalOpen(true)}
>
<ImagePlus size={14} />
</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><textarea rows={4} value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</div>
<div className="gift-table-actions identity-save-row">
<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>
);
}
// --- Login notifications ------------------------------------------------
// LoginNotificationsSection edits the 777000 login-notification message's
// per-method (phone/email) template -- a different concern from brand
// identity above even though both live in the same identity.json (see
// cmd/telesrv-admin/serversettings.go's handleSetWelcomeMessageTemplatesAPI
// doc comment), so it gets its own card and its own save action.
function LoginNotificationsSection() {
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
const [phoneTemplate, setPhoneTemplate] = useState("");
const [emailTemplate, setEmailTemplate] = useState("");
const [codeTemplate, setCodeTemplate] = useState("");
const [error, setError] = useState("");
async function load() {
setError("");
try {
const info = await api.serverIdentity();
setIdentity(info);
setPhoneTemplate(info.welcome_message_phone_template ?? "");
setEmailTemplate(info.welcome_message_email_template ?? "");
setCodeTemplate(info.login_code_message_template ?? "");
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const phoneIsOverridden = phoneTemplate.trim() !== "";
const emailIsOverridden = emailTemplate.trim() !== "";
const codeIsOverridden = codeTemplate.trim() !== "";
// Mirrors the server-side check in handleSetLoginCodeMessageTemplateAPI --
// disable the save button instead of letting the operator submit a
// template that would silently never deliver the actual OTP code.
const codeOccurrences = (codeTemplate.match(/\{\{code\}\}/g) ?? []).length;
const codeTemplateInvalid = codeIsOverridden && codeOccurrences !== 1;
return (
<section className="section-block">
<SectionHead title={"Login notifications"} />
{error && <Alert>{error}</Alert>}
{!identity ? (
<LoadingSurface label={"Loading login notification templates..."} />
) : (
<div className="card-body">
<p style={{ color: "var(--muted)", marginTop: 0 }}>
{"Sent from the official system account on every completed sign-in. Use "}
<code>{"{{server_name}}"}</code>
{" to insert the server's configured name."}
</p>
<label className="form-field">
<span>
{"Phone sign-in template"}
{" "}
{phoneIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={phoneTemplate}
onChange={(event) => setPhoneTemplate(event.target.value)}
placeholder={identity.default_welcome_message_phone_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: "", email_template: emailTemplate })}
disabled={!phoneIsOverridden}
onDone={() => { setPhoneTemplate(""); void load(); }}
/>
</div>
<label className="form-field">
<span>
{"Email sign-in template"}
{" "}
{emailIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={4}
value={emailTemplate}
onChange={(event) => setEmailTemplate(event.target.value)}
placeholder={identity.default_welcome_message_email_template}
/>
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: "" })}
disabled={!emailIsOverridden}
onDone={() => { setEmailTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login notification templates"}
path="/api/actions/set-welcome-message-templates"
payload={() => ({ phone_template: phoneTemplate, email_template: emailTemplate })}
onDone={() => void load()}
/>
</div>
<p style={{ color: "var(--muted)", marginTop: "1.5em", borderTop: "1px solid var(--line)", paddingTop: "1em" }}>
{"Sent from the official system account with every login code (SMS and email alike). Must contain "}
<code>{"{{code}}"}</code>
{" exactly once -- that's where the actual code is inserted and bolded. "}
<code>{"{{server_name}}"}</code>
{" is optional and may appear any number of times."}
</p>
<label className="form-field">
<span>
{"Login-code message template"}
{" "}
{codeIsOverridden ? <span className="badge good">{"custom"}</span> : <span className="badge">{"default"}</span>}
</span>
<textarea
rows={5}
value={codeTemplate}
onChange={(event) => setCodeTemplate(event.target.value)}
placeholder={identity.default_login_code_message_template}
/>
{codeTemplateInvalid && (
<span style={{ color: "var(--danger-text)", fontSize: "0.85em" }}>
{codeOccurrences === 0
? "Must contain {{code}} exactly once -- it is currently missing."
: `Must contain {{code}} exactly once -- it currently appears ${codeOccurrences} times.`}
</span>
)}
</label>
<div className="gift-table-actions">
<ActionButton
tone="neutral"
compact
label={"Reset to default"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: "" })}
disabled={!codeIsOverridden}
onDone={() => { setCodeTemplate(""); void load(); }}
/>
</div>
<div className="gift-table-actions identity-save-row">
<ActionButton
tone="neutral"
label={"Save login-code message template"}
path="/api/actions/set-login-code-message-template"
payload={() => ({ template: codeTemplate })}
disabled={codeTemplateInvalid}
onDone={() => void load()}
/>
</div>
</div>
)}
</section>
);
}
// autoReason skips the "why is this changing" prompt in favor of a fixed
// reason -- for the first-run wizard, where there is no prior state to
// justify changing away from and no one else's icon to be overwriting.
export function ServerIconModal({ hasIcon, onClose, onDone, autoReason }: { hasIcon: boolean; onClose: () => void; onDone: () => void; autoReason?: string }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [typedReason, setTypedReason] = useState("");
const reason = autoReason ?? typedReason;
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>
{autoReason === undefined && (
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={typedReason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setTypedReason(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
);
}
// --- .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>
);
}
// --- Services tab (live Docker + process 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.
export function useAdminRestartWatcher() {
const [waiting, setWaiting] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const cancelled = useRef(false);
const watch = useCallback(async (timeoutMs = 150000, options?: { beforeReload?: () => Promise<void> | void }) => {
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) {
// beforeReload runs against the new process (this session read
// already proved it's up) and can't fail the reload -- a reload
// an operator is staring at a spinner for shouldn't hang on it.
if (options?.beforeReload) {
await Promise.resolve(options.beforeReload()).catch(() => undefined);
}
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 };
}
// No detail line under the heading on purpose -- "restarting owpengram-server
// and the admin panel" (or Update's commit count) told the operator nothing
// they didn't already know from having just clicked Restart/Update/Finish
// setup, and this is meant to be glanced at for a few seconds, not read.
export function RestartOverlay({ timedOut, onDismiss }: { 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={timedOut ? "Restart is taking longer than expected" : "Restarting"}>
{timedOut ? (
<div className="command-body restart-overlay-body">
<div className="restart-overlay-badge warn">
<RefreshCw size={26} />
</div>
<h2 className="restart-overlay-heading">{"Still restarting..."}</h2>
<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">
<div className="restart-overlay-badge">
<RefreshCw size={26} className="restart-overlay-spin" />
</div>
<h2 className="restart-overlay-heading">{"Restarting"}</h2>
<div className="loader-bar restart-overlay-progress" />
</div>
)}
</section>
</div>,
document.body
);
}
type LiveTone = "good" | "warn" | "danger" | "idle";
function liveDotIcon(tone: LiveTone) {
switch (tone) {
case "good": return <CircleCheck size={15} />;
case "warn": return <Loader2 className="spin" size={15} />;
case "danger": return <CircleX size={15} />;
default: return <CircleOff size={15} />;
}
}
// ServiceCard renders one live status tile -- a Docker container or a local
// process -- with a status pill (dot + label) and up to one detail line.
// Shared between the Docker services grid and the process-control grid so
// both read the same way at a glance instead of two different layouts.
function ServiceCard({
icon,
name,
tone,
statusLabel,
detail
}: {
icon: React.ReactNode;
name: string;
tone: LiveTone;
statusLabel: string;
detail?: string;
}) {
return (
<div className={`service-card tone-${tone}`}>
<div className="service-card-icon">{icon}</div>
<div className="service-card-body">
<div className="service-card-name">{name}</div>
<div className="service-card-detail">{detail ?? " "}</div>
</div>
<div className="service-card-status">
{liveDotIcon(tone)}
<span>{statusLabel}</span>
</div>
</div>
);
}
const dockerServiceIcon: Record<string, React.ReactNode> = {
postgres: <Database size={18} />,
redis: <Layers size={18} />,
minio: <HardDrive size={18} />
};
function dockerTone(service: DockerService): LiveTone {
const state = service.state.toLowerCase();
const health = service.health.toLowerCase();
if (state !== "running") return "danger";
if (health === "unhealthy") return "danger";
if (health === "starting") return "warn";
return "good";
}
function dockerStatusLabel(service: DockerService): string {
const state = service.state.toLowerCase();
if (state !== "running") return service.state || "stopped";
if (service.health) return service.health;
return "running";
}
// Live polling cadence for the Services tab. Fast enough that a
// Restart/Update's effect on the process/container cards feels immediate,
// slow enough not to hammer `docker compose ps` (which shells out) every
// couple seconds for no reason.
const LIVE_POLL_MS = 4000;
// UpdateButton is a two-state control: "Check updates" (a plain git fetch +
// commit count, no side effects) until a check finds the branch behind its
// upstream, at which point it becomes "Update (<N>)" -- an ActionButton
// running the real git pull + rebuild + restart through the same
// reason/dry-run/confirm flow as Restart beside it. The check is not the
// gate: it only reports how far behind the branch is, and pulling somebody
// else's commits and relaunching the deployment deserves the same recorded
// reason as every other action here. Auto-checks once on mount so the button
// reflects reality without an operator having to click twice.
function UpdateButton({ onUpdateStarted }: { onUpdateStarted: () => void }) {
const [behind, setBehind] = useState<number | null>(null);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const check = useCallback(async () => {
setBusy(true);
setError("");
setMessage("");
try {
const result = await api.checkServerUpdates();
setBehind(result.commits_behind);
setMessage(result.commits_behind > 0
? `${result.commits_behind} new commit${result.commits_behind === 1 ? "" : "s"} pulled from GitHub.`
: "Already up to date.");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}, []);
useEffect(() => { void check(); }, [check]);
const commits = behind ?? 0;
const hasUpdates = commits > 0;
if (hasUpdates) {
return (
<ActionButton
compact
tone="danger"
icon={<Download size={15} />}
label={`Update (${commits})`}
path="/api/actions/update-server"
payload={() => ({})}
onDone={onUpdateStarted}
/>
);
}
return (
<button
className="btn compact-btn icon-text"
type="button"
disabled={busy}
title={error || message || undefined}
onClick={() => void check()}
>
{busy ? <Loader2 className="spin" size={15} /> : <RefreshCw size={15} />}
{"Check updates"}
</button>
);
}
function ServicesTab() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState("");
const [docker, setDocker] = useState<DockerService[] | null>(null);
const [dockerError, setDockerError] = useState("");
const restartWatcher = useAdminRestartWatcher();
const pausedRef = useRef(false);
pausedRef.current = restartWatcher.waiting;
const load = useCallback(async () => {
if (pausedRef.current) return;
try {
setStatus(await api.serverStatus());
setStatusError("");
} catch (err) {
setStatusError(errorMessage(err));
}
try {
setDocker(await api.dockerStatus());
setDockerError("");
} catch (err) {
setDockerError(errorMessage(err));
}
}, []);
useEffect(() => {
void load();
const id = window.setInterval(() => void load(), LIVE_POLL_MS);
return () => window.clearInterval(id);
}, [load]);
const loading = status === null && docker === null && !statusError && !dockerError;
return (
<>
<section className="section-block">
<SectionHead
title={"Services"}
action={
<div className="services-header-actions">
<UpdateButton onUpdateStarted={() => void restartWatcher.watch()} />
<ActionButton
compact
tone="primary"
label={"Restart"}
path="/api/actions/restart-server"
payload={() => ({})}
onDone={() => void restartWatcher.watch()}
/>
</div>
}
/>
{statusError && <Alert>{statusError}</Alert>}
{dockerError && <Alert>{dockerError}</Alert>}
{loading ? (
<LoadingSurface label={"Loading service status..."} />
) : (
<div className="service-grid">
{status && (
<>
<ServiceCard
icon={<Server size={18} />}
name={"Server"}
tone={status.ServerAlive ? "good" : "danger"}
statusLabel={status.ServerAlive ? "running" : "stopped"}
detail={status.ServerAlive ? `pid ${status.ServerPID}` : undefined}
/>
<ServiceCard
icon={<ShieldCheck size={18} />}
name={"admin panel"}
tone={status.AdminAlive ? "good" : "danger"}
statusLabel={status.AdminAlive ? "running" : "stopped"}
detail={status.AdminAlive ? `pid ${status.AdminPID}` : undefined}
/>
</>
)}
{docker?.map((service) => (
<ServiceCard
key={service.name}
icon={dockerServiceIcon[service.name] ?? <Database size={18} />}
name={service.name}
tone={dockerTone(service)}
statusLabel={dockerStatusLabel(service)}
detail={service.state}
/>
))}
</div>
)}
</section>
{restartWatcher.waiting && <RestartOverlay timedOut={false} onDismiss={restartWatcher.dismiss} />}
{restartWatcher.timedOut && <RestartOverlay timedOut={true} onDismiss={restartWatcher.dismiss} />}
</>
);
}

View file

@ -1,24 +1,74 @@
import { ChevronDown, Loader2, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, EmptyRow, Metric, PageFrame } from "../components/ui"; import { cacheGet, cacheKeys, cacheSet } from "../lib/cache";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, LoadingRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { displayUsername, formatBytes, formatQuantity } from "../lib/format"; import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountStorageRow, StorageStatsResponse } from "../types"; import type { AccountStorageRow, StorageStatsResponse } from "../types";
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) { type StorageSortKey = "user_id" | "username" | "bytes" | "files";
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
const [rows, setRows] = useState<AccountStorageRow[]>([]); // SortableHeader is a <th> that toggles ascending/descending on click and
// shows which column (and direction) is currently active -- there's no
// existing sortable-table convention elsewhere in this admin panel to
// mirror, so this is a small, self-contained one for this page.
function SortableHeader({
label,
sortKey,
activeKey,
desc,
onSort
}: {
label: string;
sortKey: StorageSortKey;
activeKey: StorageSortKey;
desc: boolean;
onSort: (key: StorageSortKey) => void;
}) {
const active = sortKey === activeKey;
return (
<th>
<button type="button" className="sort-header" onClick={() => onSort(sortKey)}>
{label}
{active ? (desc ? <ArrowDown size={13} /> : <ArrowUp size={13} />) : <ArrowUpDown size={13} className="sort-header-idle" />}
</button>
</th>
);
}
function StorageOverviewTab({ navigate }: { navigate: Navigate }) {
// Seeded from this session's last figures, so returning to Storage opens on
// numbers rather than on shimmering placeholders. loadStats still runs.
const [stats, setStats] = useState<StorageStatsResponse | null>(
() => cacheGet<StorageStatsResponse>(cacheKeys.storageStats) ?? null
);
// Tracked separately from `error`: loadStats deliberately swallows its
// failure so it can't block the account list, which would otherwise leave
// the metric skeletons shimmering forever on a stats-only outage.
const [statsFailed, setStatsFailed] = useState(false);
const [rows, setRows] = useState<AccountStorageRow[]>(
() => cacheGet<AccountStorageRow[]>(cacheKeys.storageAccounts) ?? []
);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [q, setQ] = useState("");
const [sortKey, setSortKey] = useState<StorageSortKey>("bytes");
const [sortDesc, setSortDesc] = useState(true);
async function loadStats() { async function loadStats() {
try { try {
setStats(await api.storageStats()); const next = await api.storageStats();
cacheSet(cacheKeys.storageStats, next);
setStats(next);
setStatsFailed(false);
} catch { } catch {
// Stats are a header nicety; a failure here shouldn't block the list. // Stats are a header nicety; a failure here shouldn't block the list.
setStatsFailed(true);
} }
} }
@ -26,11 +76,26 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
setBusy(true); setBusy(true);
setError(""); setError("");
const at = next ? offset : 0; const at = next ? offset : 0;
const params = new URLSearchParams({ limit: "50", offset: String(at) }); const params = new URLSearchParams({
limit: "50",
offset: String(at),
sort: sortKey,
order: sortDesc ? "desc" : "asc"
});
if (q.trim()) params.set("q", q.trim());
try { try {
const result = await api.storageAccounts(params); const result = await api.storageAccounts(params);
const page = result.rows ?? []; const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page)); setRows((current) => {
const merged = next ? [...current, ...page] : page;
// Only the first page is worth keeping: it is what the screen opens on,
// and caching an appended list would restore a scroll position nobody
// asked for.
if (!next) {
cacheSet(cacheKeys.storageAccounts, merged);
}
return merged;
});
setOffset(result.next_offset); setOffset(result.next_offset);
setHasMore(Boolean(result.has_more)); setHasMore(Boolean(result.has_more));
} catch (err) { } catch (err) {
@ -48,7 +113,16 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
useEffect(() => { useEffect(() => {
refresh(); refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [sortKey, sortDesc]);
function toggleSort(key: StorageSortKey) {
if (key === sortKey) {
setSortDesc((current) => !current);
} else {
setSortKey(key);
setSortDesc(true);
}
}
// Physical is what actually consumes disk/S3 (deduplicated); logical is the // Physical is what actually consumes disk/S3 (deduplicated); logical is the
// sum of what the per-account table below adds up to. They legitimately // sum of what the per-account table below adds up to. They legitimately
@ -56,41 +130,69 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
const dedupBytes = stats ? Math.max(0, Number(stats.LogicalBytes) - Number(stats.PhysicalBytes)) : 0; const dedupBytes = stats ? Math.max(0, Number(stats.LogicalBytes) - Number(stats.PhysicalBytes)) : 0;
return ( return (
<PageFrame <>
title={"Storage"}
eyebrow={"Media / Storage usage"}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} /> <Metric
<Metric label={"Logical usage (sum per account)"} value={stats ? formatBytes(stats.LogicalBytes) : "-"} /> label={"Physical usage (on disk / S3)"}
<Metric label={"Saved by dedup"} value={formatBytes(String(dedupBytes))} tone={dedupBytes > 0 ? "good" : "neutral"} /> value={stats ? formatBytes(stats.PhysicalBytes) : "-"}
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} /> loading={!stats && !statsFailed}
/>
<Metric
label={"Logical usage (sum per account)"}
value={stats ? formatBytes(stats.LogicalBytes) : "-"}
loading={!stats && !statsFailed}
/>
<Metric
label={"Saved by dedup"}
// Derived from stats, so before they land this is a placeholder 0 --
// it has to shimmer like the rest rather than assert "0 B", which
// reads as a real measurement of nothing saved.
value={formatBytes(String(dedupBytes))}
loading={!stats && !statsFailed}
tone={dedupBytes > 0 ? "good" : "neutral"}
/>
<Metric label={"Backend"} value={stats?.BackendKind ?? "-"} loading={!stats && !statsFailed} />
</div> </div>
<div className="metric-row"> <div className="metric-row">
<Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} /> <Metric label={"Documents"} value={stats ? formatQuantity(stats.DocumentCount) : "-"} loading={!stats && !statsFailed} />
<Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} /> <Metric label={"Photos"} value={stats ? formatQuantity(stats.PhotoCount) : "-"} loading={!stats && !statsFailed} />
<Metric label={"Accounts with media"} value={stats ? formatQuantity(stats.AccountCount) : "-"} />
<Metric <Metric
label={"Unattributed"} label={"Accounts with media"}
value={stats ? formatBytes(stats.UnattributedBytes) : "-"} value={stats ? formatQuantity(stats.AccountCount) : "-"}
tone={stats && Number(stats.UnattributedBytes) > 0 ? "warn" : "neutral"} loading={!stats && !statsFailed}
/>
<Metric
label={"System/bundled content"}
value={stats ? formatBytes(stats.SystemBytes) : "-"}
loading={!stats && !statsFailed}
/> />
</div> </div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadAccounts(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / username / name"} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</form>
</QueryPanel>
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{"User ID"}</th> <SortableHeader label={"User ID"} sortKey="user_id" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
<th>{"Account"}</th> <SortableHeader label={"Account"} sortKey="username" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
<th>{"Storage used"}</th> <SortableHeader label={"Storage used"} sortKey="bytes" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
<th>{"Files"}</th> <SortableHeader label={"Files"} sortKey="files" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -100,9 +202,10 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td> <td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
<td className="mono">{formatBytes(row.Bytes)}</td> <td className="mono">{formatBytes(row.Bytes)}</td>
<td className="mono">{formatQuantity(row.FileCount)}</td> <td className="mono">{formatQuantity(row.FileCount)}</td>
<td><button className="row-link" type="button" onClick={() => navigate(`/accounts/${row.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{rows.length === 0 && <EmptyRow colSpan={4} />} {rows.length === 0 && (busy ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -113,6 +216,654 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
</button> </button>
</div> </div>
)} )}
</>
);
}
export function StoragePage({ navigate }: { navigate: Navigate }) {
const [tab, setTab] = useState<"overview" | "limits">("overview");
return (
<PageFrame title={"Storage"} eyebrow={"Media / Storage usage"}>
<div className="tab-bar" role="tablist" aria-label={"Storage sections"}>
<button className={`tab-btn ${tab === "overview" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "overview"} onClick={() => setTab("overview")}>
{"Overview"}
</button>
<button className={`tab-btn ${tab === "limits" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "limits"} onClick={() => setTab("limits")}>
{"Limits & Retention"}
</button>
</div>
{tab === "overview" ? <StorageOverviewTab navigate={navigate} /> : <LimitsRetentionSection />}
</PageFrame> </PageFrame>
); );
} }
// --- Limits & Retention --------------------------------------------------
//
// Edits a handful of TELESRV_STORAGE_* keys through the existing generic
// .env editor endpoints (GET /api/server/env, POST
// /api/actions/update-server-env -- see cmd/telesrv-admin/serversettings.go
// and ServerSettingsPage.tsx's EnvSection, which this mirrors for its
// save/reload flow), but with friendly units instead of raw key=value text:
// GB inputs for byte budgets, a mode dropdown + day count for retention.
const STORAGE_ENV_KEYS = {
blobBackend: "TELESRV_BLOB_BACKEND",
maxTotal: "TELESRV_STORAGE_MAX_TOTAL_BYTES",
minFree: "TELESRV_STORAGE_MIN_FREE_BYTES",
maxUploadFile: "TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES",
retentionMode: "TELESRV_STORAGE_RETENTION_MODE",
retentionMaxAge: "TELESRV_STORAGE_RETENTION_MAX_AGE",
evictionEnable: "TELESRV_STORAGE_EVICTION_ENABLE"
} as const;
// Per-category retention age overrides -- each optional, empty/0 means
// "inherit the shared Retention age" above. Order matches the admin UI list.
const CATEGORY_AGE_FIELDS: { key: string; envKey: string; label: string }[] = [
{ key: "photo", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_PHOTO", label: "Photo" },
{ key: "video", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_VIDEO", label: "Video" },
{ key: "round_video", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_ROUND_VIDEO", label: "Round video (video message)" },
{ key: "gif", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_GIF", label: "GIF" },
{ key: "music", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_MUSIC", label: "Music" },
{ key: "voice", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_VOICE", label: "Voice message" },
{ key: "file", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_FILE", label: "File" },
{ key: "avatar", envKey: "TELESRV_STORAGE_RETENTION_MAX_AGE_AVATAR", label: "Avatar" }
];
// DANGER_ZONE_CATEGORY_KEYS marks the two CATEGORY_AGE_FIELDS/manual-purge
// categories that reach beyond ordinary per-user media: Avatar also covers
// the built-in system bots' own profile photos, and GIF also covers the
// server's bundled GIF catalog (@gif). CategoryRetentionModal and
// ManualPurgeStorageModal both split these into their own boxed "Danger
// zone" section instead of listing them alongside Photo/Video/Music/etc.
const DANGER_ZONE_CATEGORY_KEYS = new Set(["gif", "avatar"]);
// Mirrors internal/app/files.MaxUploadPartBytes * MaxUploadParts -- the
// protocol's own upload-part-count ceiling that TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES
// can never legally exceed (internal/config validates this server-side too;
// this is just an early, friendlier warning in the form).
const PROTOCOL_UPLOAD_CEILING_BYTES = 524288 * 8000;
const BYTE_UNITS: { label: string; bytes: number }[] = [
{ label: "MB", bytes: 1024 ** 2 },
{ label: "GB", bytes: 1024 ** 3 },
{ label: "TB", bytes: 1024 ** 4 }
];
// Max single file size can never usefully exceed the protocol's own per-file
// ceiling (PROTOCOL_UPLOAD_CEILING_BYTES, ~4000MiB) -- so its unit picker
// drops TB entirely (a single file will never be measured in terabytes) and
// caps the amount at a round 4096MB/4GB (both the same byte value, since
// these units are binary) instead of letting the input accept an arbitrarily
// large number that Save would just reject anyway.
const MAX_FILE_SIZE_UNITS = BYTE_UNITS.filter((u) => u.label !== "TB");
const MAX_FILE_SIZE_CAP_BYTES = 4 * 1024 ** 3;
function bestByteUnit(bytes: number, units: { label: string; bytes: number }[] = BYTE_UNITS): { label: string; bytes: number } {
for (let i = units.length - 1; i >= 0; i--) {
if (bytes >= units[i].bytes) return units[i];
}
return units[Math.min(1, units.length - 1)]; // default to GB (or the 2nd unit) for small/zero values
}
// parseDurationMinutes reads a Go-style duration string (e.g. "720h", "90m",
// "1h30m") and returns the total as minutes. Unrecognized/empty input is 0.
function parseDurationMinutes(value: string): number {
let totalSeconds = 0;
const re = /(\d+(?:\.\d+)?)\s*(h|m|s)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(value)) !== null) {
const amount = parseFloat(match[1]);
const unit = match[2];
totalSeconds += unit === "h" ? amount * 3600 : unit === "m" ? amount * 60 : amount;
}
return totalSeconds / 60;
}
const DURATION_UNITS: { label: string; minutes: number }[] = [
{ label: "Minutes", minutes: 1 },
{ label: "Hours", minutes: 60 },
{ label: "Days", minutes: 1440 }
];
function bestDurationUnit(minutes: number): { label: string; minutes: number } {
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
if (minutes >= DURATION_UNITS[i].minutes) return DURATION_UNITS[i];
}
return DURATION_UNITS[0];
}
// DurationField edits one duration env value (stored as total minutes) as a
// friendly "amount + unit" pair, letting TTL be set in minutes, hours, or
// days rather than being locked to whole days.
function DurationField({
label,
help,
minutes,
disabled,
onChange
}: {
label: string;
help: string;
minutes: string;
disabled?: boolean;
onChange: (minutes: string) => void;
}) {
const totalMinutes = Number(minutes || "0");
const [unitLabel, setUnitLabel] = useState(() => bestDurationUnit(totalMinutes).label);
const unit = DURATION_UNITS.find((u) => u.label === unitLabel) ?? DURATION_UNITS[2];
// 0 is a real, distinct value here (e.g. the shared Retention age
// deliberately set to 0 to disable the default sweep) -- unlike
// ByteSizeField's "0/empty = Unlimited" convention, this must not collapse
// to a blank input, or there'd be no way to tell "0" from "not typed yet".
const amount = Number.isFinite(totalMinutes) ? totalMinutes / unit.minutes : NaN;
function handleAmountChange(raw: string) {
if (!raw.trim()) {
onChange("0");
return;
}
const parsed = Number(raw);
if (Number.isNaN(parsed) || parsed < 0) {
onChange("0");
return;
}
if (parsed === 0) {
onChange("0");
return;
}
onChange(String(Math.max(1, Math.round(parsed * unit.minutes))));
}
return (
<label className="duration-field">
<span>{label}</span>
<div style={{ display: "flex", gap: 8 }}>
<input
type="number"
min="0"
step="any"
value={Number.isNaN(amount) ? "" : amount}
placeholder={"0"}
disabled={disabled}
onChange={(event) => handleAmountChange(event.target.value)}
/>
<select value={unitLabel} disabled={disabled} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 100 }}>
{DURATION_UNITS.map((u) => (
<option key={u.label} value={u.label}>{u.label}</option>
))}
</select>
</div>
<span className="env-field-desc">{help}</span>
</label>
);
}
// ByteSizeField edits one byte-count env value as a friendly
// "amount + unit" pair. bytes is the raw byte count as a string (what the
// backend stores); an empty/zero value displays as "Unlimited". units/maxBytes
// let a caller narrow this down for a field with its own hard ceiling (e.g.
// Max single file size, capped at the protocol's own ~4GB per-file limit --
// there's no point offering TB there, or letting MB/GB amounts go past it).
function ByteSizeField({
label,
help,
bytes,
onChange,
units = BYTE_UNITS,
maxBytes
}: {
label: string;
help: string;
bytes: string;
onChange: (bytes: string) => void;
units?: { label: string; bytes: number }[];
maxBytes?: number;
}) {
const numericBytes = Number(bytes || "0");
const [unitLabel, setUnitLabel] = useState(() => bestByteUnit(numericBytes, units).label);
const unit = units.find((u) => u.label === unitLabel) ?? units[0];
const amount = numericBytes > 0 ? numericBytes / unit.bytes : NaN;
function handleAmountChange(raw: string) {
const parsed = Number(raw);
if (!raw.trim() || Number.isNaN(parsed) || parsed <= 0) {
onChange("0");
return;
}
let value = Math.round(parsed * unit.bytes);
if (maxBytes && value > maxBytes) value = maxBytes;
onChange(String(value));
}
return (
<label className="duration-field">
<span>{label}</span>
<div style={{ display: "flex", gap: 8 }}>
<input
type="number"
min="0"
max={maxBytes ? maxBytes / unit.bytes : undefined}
step="any"
value={Number.isNaN(amount) ? "" : amount}
placeholder={"Unlimited"}
onChange={(event) => handleAmountChange(event.target.value)}
/>
<select value={unitLabel} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 90 }}>
{units.map((u) => (
<option key={u.label} value={u.label}>{u.label}</option>
))}
</select>
</div>
<span className="env-field-desc">{help}</span>
</label>
);
}
// CategoryRetentionModal is a focused editor for the per-category age
// overrides -- pulled out of the main Limits & Retention flow (which was
// getting crowded with 8 extra duration fields) into its own dialog, following
// the same modal-backdrop/command-modal pattern as MintCollectibleUsernameModal.
// It edits the same categoryAgeMinutes state the parent already owns; there's
// no separate save here, just Close -- the one shared "Save limits &
// retention settings" button below still covers these values too.
function CategoryRetentionModal({
categoryAgeMinutes,
onChange,
disabled,
onClose
}: {
categoryAgeMinutes: Record<string, string>;
onChange: (key: string, minutes: string) => void;
disabled: boolean;
onClose: () => void;
}) {
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Per-category retention overrides"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Limits & Retention"}</div>
<h2>{"Per-category overrides"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p className="env-field-desc">
{"Leave a category at 0 to inherit the shared Retention age. The mode switch still applies to all of them -- these only change how old that one category's media must be."}
</p>
{CATEGORY_AGE_FIELDS.filter((f) => !DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
<DurationField
key={field.key}
label={field.label}
help={"Inherits the shared Retention age when left at 0."}
minutes={categoryAgeMinutes[field.key] || "0"}
disabled={disabled}
onChange={(minutes) => onChange(field.key, minutes)}
/>
))}
<div className="danger-zone-box">
<div className="danger-zone-box-title">{"Danger zone"}</div>
<div className="danger-zone-box-body">
<p className="env-field-desc">
{"Also affects built-in system bot avatars and the bundled GIF catalog (@gif), not just user media."}
</p>
{CATEGORY_AGE_FIELDS.filter((f) => DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
<DurationField
key={field.key}
label={field.label}
help={"Inherits the shared Retention age when left at 0."}
minutes={categoryAgeMinutes[field.key] || "0"}
disabled={disabled}
onChange={(minutes) => onChange(field.key, minutes)}
/>
))}
</div>
</div>
</div>
<div className="modal-actions">
<button className="btn primary" type="button" onClick={onClose}>{"Close"}</button>
</div>
</section>
</div>,
document.body
);
}
// ManualPurgeStorageModal lets an operator delete media blob bytes right now
// by hand-picked category (and, optionally, avatars) with an optional
// "created before" age cutoff -- the manual counterpart of the automatic
// hard-retention sweep above. Leaving the date empty purges everything
// matching the selected categories regardless of age. Deletion semantics are
// identical to "hard" retention mode: only the file bytes are removed, never
// the document/photo row, so an affected message still renders its
// placeholder (see internal/app/files.Service.ManualPurge's doc comment).
// Follows the same modal-backdrop/command-modal structure as
// CategoryRetentionModal, but hands the actual mutation off to ActionButton
// (reason + dry-run + confirm) since -- unlike the settings above -- this is
// an immediate, irreversible delete rather than a saved .env value.
function ManualPurgeStorageModal({ onClose }: { onClose: () => void }) {
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [dateValue, setDateValue] = useState("");
const documentFields = CATEGORY_AGE_FIELDS.filter((f) => f.key !== "avatar");
const allSelected = CATEGORY_AGE_FIELDS.every((f) => selected[f.key]);
function toggle(key: string) {
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
}
function toggleAll() {
const next = !allSelected;
const nextSelected: Record<string, boolean> = {};
for (const field of CATEGORY_AGE_FIELDS) nextSelected[field.key] = next;
setSelected(nextSelected);
}
const chosenCategories = documentFields.filter((f) => selected[f.key]).map((f) => f.key);
const includeAvatars = Boolean(selected.avatar);
const canSubmit = chosenCategories.length > 0 || includeAvatars;
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Manually purge storage"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Limits & Retention"}</div>
<h2>{"Manually purge storage"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p className="env-field-desc">
{"Deletes the file bytes of every document/photo matching the categories below, right now -- independent of the retention mode/age configured above. The message/profile-photo itself is never deleted, only its file; a purged item starts showing as unavailable. Leave \"Created before\" empty to purge everything in the selected categories, regardless of age."}
</p>
<div className="attr-block">
<label className="checkline">
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
{" "}{"Select all"}
</label>
{CATEGORY_AGE_FIELDS.filter((f) => !DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
<label key={field.key} className="checkline">
<input type="checkbox" checked={Boolean(selected[field.key])} onChange={() => toggle(field.key)} />
{" "}{field.label}
</label>
))}
</div>
<div className="danger-zone-box">
<div className="danger-zone-box-title">{"Danger zone"}</div>
<div className="danger-zone-box-body">
<p className="env-field-desc">
{"Avatar also purges system bot avatars (auto-restored on next restart). GIF also purges the bundled GIF catalog (@gif) -- permanently, unless its source file is still in TELESRV_GIF_SEED_DIR."}
</p>
{CATEGORY_AGE_FIELDS.filter((f) => DANGER_ZONE_CATEGORY_KEYS.has(f.key)).map((field) => (
<label key={field.key} className="checkline">
<input type="checkbox" checked={Boolean(selected[field.key])} onChange={() => toggle(field.key)} />
{" "}{field.label}
</label>
))}
</div>
</div>
<label className="duration-field">
<span>{"Created before (optional)"}</span>
<input type="date" value={dateValue} onChange={(event) => setDateValue(event.target.value)} />
<span className="env-field-desc">{"Empty = no age limit, purge everything matching the selected categories."}</span>
</label>
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<ActionButton
disabled={!canSubmit}
label={"Purge selected storage"}
tone="danger"
path="/api/actions/storage-manual-purge"
payload={() => ({
categories: chosenCategories,
include_avatars: includeAvatars,
created_before: dateValue ? new Date(dateValue).toISOString() : undefined
})}
/>
</div>
</section>
</div>,
document.body
);
}
function LimitsRetentionSection() {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const [initial, setInitial] = useState<Record<string, string>>({});
const [blobBackend, setBlobBackend] = useState("s3");
const [maxTotalBytes, setMaxTotalBytes] = useState("0");
const [minFreeBytes, setMinFreeBytes] = useState("0");
const [maxUploadFileBytes, setMaxUploadFileBytes] = useState("0");
const [retentionMode, setRetentionMode] = useState("off");
const [retentionAgeMinutes, setRetentionAgeMinutes] = useState("43200");
const [categoryAgeMinutes, setCategoryAgeMinutes] = useState<Record<string, string>>({});
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
const [evictionEnable, setEvictionEnable] = useState(false);
const [manualPurgeOpen, setManualPurgeOpen] = useState(false);
async function load() {
setError("");
try {
const groups = await api.serverEnv();
const values: Record<string, string> = {};
for (const group of groups) {
for (const field of group.fields) {
values[field.key] = field.value || field.default_value || "";
}
}
setInitial(values);
setBlobBackend((values[STORAGE_ENV_KEYS.blobBackend] || "s3").trim().toLowerCase());
setMaxTotalBytes(values[STORAGE_ENV_KEYS.maxTotal] || "0");
setMinFreeBytes(values[STORAGE_ENV_KEYS.minFree] || "0");
setMaxUploadFileBytes(values[STORAGE_ENV_KEYS.maxUploadFile] || "0");
const mode = (values[STORAGE_ENV_KEYS.retentionMode] || "off").trim().toLowerCase();
setRetentionMode(mode === "orphan" || mode === "hard" ? mode : "off");
// "720h" is only a stand-in for a genuinely UNSET value (empty string,
// e.g. a fresh install) -- an explicit "0m" is a real, meaningful
// setting (retention disabled by default, relying purely on
// Per-category overrides) and must display as 0, not silently get
// coerced back to the 30-day default the moment this page reloads.
const rawRetentionMaxAge = values[STORAGE_ENV_KEYS.retentionMaxAge];
const mins = parseDurationMinutes(rawRetentionMaxAge || "720h");
setRetentionAgeMinutes(String(Math.max(0, Math.round(mins))));
const nextCategoryAges: Record<string, string> = {};
for (const field of CATEGORY_AGE_FIELDS) {
const raw = values[field.envKey] || "";
const categoryMins = parseDurationMinutes(raw);
nextCategoryAges[field.key] = categoryMins > 0 ? String(Math.round(categoryMins)) : "0";
}
setCategoryAgeMinutes(nextCategoryAges);
setEvictionEnable((values[STORAGE_ENV_KEYS.evictionEnable] || "false").trim().toLowerCase() === "true");
setLoaded(true);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
// Only the age is re-encoded from its friendly "days" input back to a Go
// duration string; mode/off keeps whatever TELESRV_STORAGE_RETENTION_MAX_AGE
// already was rather than clobbering it with a throwaway placeholder.
const pendingValues = useMemo(() => {
const next: Record<string, string> = {
[STORAGE_ENV_KEYS.maxTotal]: maxTotalBytes || "0",
[STORAGE_ENV_KEYS.minFree]: minFreeBytes || "0",
[STORAGE_ENV_KEYS.maxUploadFile]: maxUploadFileBytes || "0",
[STORAGE_ENV_KEYS.retentionMode]: retentionMode,
[STORAGE_ENV_KEYS.retentionMaxAge]: retentionMode === "off"
? (initial[STORAGE_ENV_KEYS.retentionMaxAge] || "720h")
// 0 is a legitimate value here: "no sweep by default", left for
// per-category overrides below to opt specific categories in. Don't
// clamp it up to a minimum of 1 -- that used to make it impossible to
// ever save a 0 global age.
: `${Math.max(0, Math.round(Number(retentionAgeMinutes || "0")))}m`,
[STORAGE_ENV_KEYS.evictionEnable]: evictionEnable ? "true" : "false"
};
// Per-category overrides: a field left at "inherit global" (0) saves as
// empty rather than a redundant explicit duration, matching the "unset
// means inherit" contract on the server side.
for (const field of CATEGORY_AGE_FIELDS) {
const mins = Math.round(Number(categoryAgeMinutes[field.key] || "0"));
next[field.envKey] = mins > 0 ? `${mins}m` : "";
}
const changed: Record<string, string> = {};
for (const [key, value] of Object.entries(next)) {
if ((initial[key] ?? "") !== value) changed[key] = value;
}
return changed;
}, [maxTotalBytes, minFreeBytes, maxUploadFileBytes, retentionMode, retentionAgeMinutes, categoryAgeMinutes, evictionEnable, initial]);
const hasChanges = Object.keys(pendingValues).length > 0;
const uploadCeilingExceeded = Number(maxUploadFileBytes || "0") > PROTOCOL_UPLOAD_CEILING_BYTES;
return (
<section className="section-block">
<SectionHead
title={"Limits & Retention"}
text={"Server-wide storage budget, per-file upload cap, and automatic media cleanup. Saved to .env -- takes effect on the next Restart/Update."}
/>
{error && <Alert>{error}</Alert>}
{!loaded ? (
<p style={{ color: "var(--muted)" }}>{"Loading current settings..."}</p>
) : (
<div className="card-body">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Size limits"} />
<div className="card-body">
<div className="attr-block">
<ByteSizeField
label={"Max total storage budget"}
help={"Reject new uploads once total tracked blob bytes would exceed this. Empty/0 = unlimited."}
bytes={maxTotalBytes}
onChange={setMaxTotalBytes}
/>
{blobBackend === "localfs" && (
<ByteSizeField
label={"Min free space guard"}
help={"localfs backend only: reject new uploads once real free disk space falls below this. Empty/0 disables the check."}
bytes={minFreeBytes}
onChange={setMinFreeBytes}
/>
)}
<ByteSizeField
label={"Max single file size"}
help={"Reject a single upload once its total assembled size exceeds this. Empty/0 = unlimited, bounded only by the protocol's own ~4GB per-file ceiling."}
bytes={maxUploadFileBytes}
onChange={setMaxUploadFileBytes}
units={MAX_FILE_SIZE_UNITS}
maxBytes={MAX_FILE_SIZE_CAP_BYTES}
/>
{uploadCeilingExceeded && (
<Alert>{"This exceeds the protocol's own ~4GB upload ceiling and will be refused when the server restarts."}</Alert>
)}
</div>
</div>
</section>
<section className="section-block">
<SectionHead
title={"Retention"}
action={
<button className="btn compact-btn icon-text" type="button" onClick={() => setCategoryModalOpen(true)}>
<Settings2 size={14} /> {"Per-category..."}
</button>
}
/>
<div className="card-body">
<div className="attr-block">
<label className="duration-field">
<span>{"Retention mode"}</span>
<select value={retentionMode} onChange={(event) => setRetentionMode(event.target.value)}>
<option value="off">{"Off"}</option>
<option value="orphan">{"Delete once no longer used (safe)"}</option>
<option value="hard">{"Delete after a fixed time, even if still in use"}</option>
</select>
</label>
<p className="env-field-desc">
{retentionMode === "off" &&
"No storage sweep runs; nothing is auto-deleted. Storage usage is still tracked and shown above either way."}
{retentionMode === "orphan" &&
"Safe: a document or photo's file is deleted only once it is no longer referenced by any message, profile photo, or sticker set. Media still visible in a conversation is never touched, regardless of age."}
{retentionMode === "hard" &&
"Irreversible and aggressive: a document or photo's file bytes are deleted once old enough, REGARDLESS of whether a message still references it. Old media in active conversations will start showing as unavailable once purged -- only the file is removed, the message itself keeps rendering its placeholder (name, size, thumbnail)."}
</p>
<DurationField
label={"Retention age"}
help={
(retentionMode === "hard"
? "How old the media itself must be, counted from when it was uploaded, before its bytes are purged."
: "How long a document or photo must have had zero references before its file is deleted.") +
" Set to 0 to disable the sweep by default and only clean up categories you explicitly override via Per-category."
}
minutes={retentionAgeMinutes}
disabled={retentionMode === "off"}
onChange={setRetentionAgeMinutes}
/>
</div>
</div>
</section>
<section className="section-block">
<SectionHead title={"Reclaim space"} />
<div className="card-body">
<div className="attr-block">
<label className="checkline">
<input
type="checkbox"
checked={evictionEnable}
onChange={(event) => setEvictionEnable(event.target.checked)}
/>
{" "}{"Actively reclaim space once over budget"}
</label>
<p className="env-field-desc">
{"Once total physical storage exceeds the Max total storage budget, actively delete the oldest files (regardless of category or age) until back under budget -- the same way \"hard\" retention mode purges files. Independent of the retention mode: this can run even when that's Off. Off by default, since this changes the storage budget from block-new-uploads-only to also reclaiming from existing files."}
</p>
</div>
</div>
</section>
<section className="section-block">
<SectionHead title={"Manual purge"} />
<div className="card-body">
<div className="attr-block">
<button className="btn danger icon-text" type="button" onClick={() => setManualPurgeOpen(true)}>
<Settings2 size={15} /> {"Manually purge storage..."}
</button>
<span className="env-field-desc">
{"Delete media file bytes right now by hand-picked category and an optional age cutoff, independent of the retention settings above."}
</span>
</div>
</div>
</section>
</div>
{categoryModalOpen && (
<CategoryRetentionModal
categoryAgeMinutes={categoryAgeMinutes}
onChange={(key, minutes) => setCategoryAgeMinutes((prev) => ({ ...prev, [key]: minutes }))}
disabled={retentionMode === "off"}
onClose={() => setCategoryModalOpen(false)}
/>
)}
{manualPurgeOpen && <ManualPurgeStorageModal onClose={() => setManualPurgeOpen(false)} />}
<div className="gift-table-actions env-save-row">
<ActionButton
tone="warn"
label={"Save limits & retention settings"}
path="/api/actions/update-server-env"
payload={() => ({ values: pendingValues })}
disabled={!hasChanges || uploadCeilingExceeded}
onDone={() => void load()}
/>
</div>
</div>
)}
</section>
);
}

View file

@ -1,10 +1,13 @@
import { ShieldOff } from "lucide-react"; import { EyeOff, ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui"; import { StatusScreen } from "./components/StatusScreen";
import type { Navigate } from "./routing";
// Permission names exactly as the backend spells them // Permission names exactly as the backend spells them
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for // (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session. // a full-access session.
export const permissionAll = "*"; export const permissionAll = "*";
export const permissionPremiumManage = "premium.manage";
export const permissionBotTokenRead = "bots.token.read";
export const permissionVerificationReview = "verification.review"; export const permissionVerificationReview = "verification.review";
export const permissionVerificationRevoke = "verification.revoke"; export const permissionVerificationRevoke = "verification.revoke";
// Third-party verification is a separate mechanism and therefore a separate pair of // Third-party verification is a separate mechanism and therefore a separate pair of
@ -12,6 +15,25 @@ export const permissionVerificationRevoke = "verification.revoke";
// verifier roster, the icon catalogue and taking a granted mark away. // verifier roster, the icon catalogue and taking a granted mark away.
export const permissionBotVerificationReview = "botverification.review"; export const permissionBotVerificationReview = "botverification.review";
export const permissionBotVerificationManage = "botverification.manage"; 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";
// Operator accounts. The one right that can hand out every other right, so it
// is never implied by anything else -- see the constant's doc comment in
// security.go.
export const permissionAdminsManage = "admins.manage";
// Section rights, in read/manage pairs following the sidebar -- see the const
// block in security.go, which these must match exactly.
export const permissionAccountsRead = "accounts.read";
export const permissionChannelsRead = "channels.read";
export const permissionBotsRead = "bots.read";
export const permissionMessagesRead = "messages.read";
export const permissionModerationReview = "moderation.review";
export const permissionBroadcastsRead = "broadcasts.read";
export const permissionStorageRead = "storage.read";
export const permissionContentRead = "content.read";
export const permissionUsernamesRead = "usernames.read";
export const permissionDashboardRead = "dashboard.read";
// GET /api/session is read once at boot; the panel keeps the answer here so a // 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 // section the session may not use is hidden instead of rendered into a 403. This
@ -64,51 +86,178 @@ export function useThirdPartyVerificationHidden(): boolean {
} }
// PermissionGate is what a direct URL hits: without the right the operator gets // PermissionGate is what a direct URL hits: without the right the operator gets
// an explanation naming the missing permission, not an empty table that looks // a proper refusal naming the missing permission, not an empty table that looks
// like "no data". // like "no data".
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) { export function PermissionGate({
permission,
navigate,
children
}: {
permission: string;
navigate?: Navigate;
children: ReactNode;
}) {
const { can } = usePermissions(); const { can } = usePermissions();
if (can(permission)) { if (can(permission)) {
return <>{children}</>; return <>{children}</>;
} }
return <PermissionDenied permission={permission} />; return <PermissionDenied permission={permission} navigate={navigate} />;
} }
export function PermissionDenied({ permission }: { permission: string }) { export function PermissionDenied({ permission, navigate }: { permission: string; navigate?: Navigate }) {
return ( return (
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}> <StatusScreen
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert> code="403"
<section className="section-block"> icon={ShieldOff}
<div className="entity-head"> title={"You do not have access to this section"}
<div> detail={permission}
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div> navigate={navigate}
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div> >
</div> {/* Named in the same words the operator editor uses, so "Reveal bot
</div> tokens" is what gets asked for rather than "bots.token.read". The raw
</section> string is still shown below, because that is what has to be ticked. */}
</PageFrame> {`It needs the "${permissionTitle(permission)}" permission. Ask an operator who can manage operators to add it, then sign in again.`}
</StatusScreen>
); );
} }
// ThirdPartyVerificationHiddenGate is what a direct URL to a third-party // ThirdPartyVerificationHiddenGate is what a direct URL to a third-party
// verification page hits while the feature is hidden -- distinct from // verification page hits while the feature is hidden -- distinct from
// PermissionGate because no permission grant (not even "*") changes this. // PermissionGate because no permission grant (not even "*") changes this.
export function ThirdPartyVerificationHiddenGate({ children }: { children: ReactNode }) { export function ThirdPartyVerificationHiddenGate({
navigate,
children
}: {
navigate?: Navigate;
children: ReactNode;
}) {
const hidden = useThirdPartyVerificationHidden(); const hidden = useThirdPartyVerificationHidden();
if (!hidden) { if (!hidden) {
return <>{children}</>; return <>{children}</>;
} }
return ( return (
<PageFrame title={"Feature hidden"} eyebrow={"Console / Third-party marks"}> <StatusScreen
<Alert>{"Third-party bot verification is hidden on this server (TELESRV_HIDE_THIRD_PARTY_VERIFICATION=true)."}</Alert> code="404"
<section className="section-block"> icon={EyeOff}
<div className="entity-head"> title={"This section is switched off"}
<div> detail="TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false"
<div className="entity-title"><ShieldOff size={16} /> {"Not fully finished"}</div> navigate={navigate}
<div className="entity-subtitle">{"This feature may cause unstable server behavior and is hidden by default. Set TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false to re-enable it."}</div> >
</div> {"Third-party bot verification is not finished and is hidden on this server. It is a server setting, not a permission -- no account can see it while it is off."}
</div> </StatusScreen>
</section>
</PageFrame>
); );
} }
// Human-readable names for the permission strings. The raw value is what the
// backend stores and checks, but "content.manage" is a machine's word for it --
// an operator ticking boxes should read what the right actually lets someone do.
//
// Anything missing from this map falls back to the raw string rather than being
// hidden, so a right added on the server still appears (just untranslated)
// instead of silently vanishing from the editor.
const permissionLabels: Record<string, { title: string; hint: string }> = {
"accounts.read": { title: "View accounts", hint: "Browse users, their profiles and sessions" },
"accounts.manage": { title: "Edit accounts", hint: "Change profiles, usernames, freeze and revoke sessions" },
"channels.read": { title: "View groups and channels", hint: "Browse supergroups and channels" },
"channels.manage": { title: "Edit groups and channels", hint: "Change settings, usernames and avatars" },
"bots.read": { title: "View bots", hint: "Browse the bot list and their details" },
"bots.manage": { title: "Create and delete bots", hint: "Add new bots and remove existing ones" },
"bots.token.read": { title: "Reveal bot tokens", hint: "Export a bot's live credential" },
"messages.read": { title: "View messages", hint: "Read private and group message history" },
"messages.manage": { title: "Delete messages", hint: "Remove messages and clear history" },
"moderation.review": { title: "Handle reports", hint: "Work the moderation queue and decide cases" },
"broadcasts.read": { title: "View broadcasts", hint: "See past and scheduled broadcasts" },
"broadcasts.send": { title: "Send broadcasts", hint: "Deliver a message to many users at once" },
"content.read": { title: "View stickers, emoji and GIFs", hint: "Browse the packs and the GIF catalogue" },
"content.manage": { title: "Edit stickers, emoji and GIFs", hint: "Create, rename and remove packs and catalogue entries" },
"usernames.read": { title: "View NFT usernames", hint: "Browse collectible usernames" },
"usernames.manage": { title: "Manage NFT usernames", hint: "Mint, transfer and revoke collectible usernames" },
"storage.read": { title: "View storage", hint: "See media usage per account" },
"storage.manage": { title: "Purge storage", hint: "Manually delete stored media" },
"dashboard.read": { title: "View the dashboard", hint: "See the overview counters and server health" },
"premium.manage": { title: "Manage Premium", hint: "Grant, revoke and refund Premium" },
"verification.review": { title: "Verify accounts", hint: "Work the verification queue and grant badges" },
"verification.revoke": { title: "Remove verification", hint: "Take a granted badge away (needs the right above too)" },
"botverification.review": { title: "Handle third-party marks", hint: "Work the third-party verification queue" },
"botverification.manage": { title: "Appoint verifiers", hint: "Grant verifier status and curate mark icons" },
"server.manage": { title: "Server settings", hint: "Identity, .env editing, restart and update" },
"admins.manage": { title: "Manage operators", hint: "Create operators and decide what everyone can do" },
"*": { title: "Full access", hint: "Every right, including future ones" }
};
export function permissionTitle(permission: string): string {
return permissionLabels[permission]?.title ?? permission;
}
export function permissionHint(permission: string): string {
return permissionLabels[permission]?.hint ?? "";
}
// Rights grouped by the part of the console they govern, so the editor reads as
// a few short decisions instead of one wall of twenty-six checkboxes.
//
// The order is roughly "everyday work first, keys to the building last": an
// operator scanning down the list meets the routine rights before the ones that
// can undo the deployment.
export const permissionGroups: { title: string; hint: string; permissions: string[] }[] = [
{
title: "People and chats",
hint: "Users, groups and their message history",
permissions: ["accounts.read", "accounts.manage", "channels.read", "channels.manage", "messages.read", "messages.manage"]
},
{
title: "Moderation and verification",
hint: "Reports, badges and third-party marks",
permissions: ["moderation.review", "verification.review", "verification.revoke", "botverification.review", "botverification.manage"]
},
{
title: "Content",
hint: "Sticker packs, emoji, GIFs and collectible usernames",
permissions: ["content.read", "content.manage", "usernames.read", "usernames.manage"]
},
{
title: "Bots",
hint: "The bot roster and its credentials",
permissions: ["bots.read", "bots.manage", "bots.token.read"]
},
{
title: "Broadcasting",
hint: "Messages sent to many users at once",
permissions: ["broadcasts.read", "broadcasts.send"]
},
{
title: "Storage and overview",
hint: "Media usage and the dashboard",
permissions: ["storage.read", "storage.manage", "dashboard.read"]
},
{
title: "Billing",
hint: "Premium grants and refunds",
permissions: ["premium.manage"]
},
{
title: "The console itself",
hint: "The two rights that can change the deployment or hand out every other right",
permissions: ["server.manage", "admins.manage"]
}
];
// groupPermissions arranges the server's list into the groups above. Anything
// the server offers that no group claims is collected at the end rather than
// dropped, so a right added on the backend still appears here without this file
// having to be edited first.
export function groupPermissions(available: string[]): { title: string; hint: string; permissions: string[] }[] {
const remaining = new Set(available);
const out: { title: string; hint: string; permissions: string[] }[] = [];
for (const group of permissionGroups) {
const present = group.permissions.filter((p) => remaining.has(p));
present.forEach((p) => remaining.delete(p));
if (present.length > 0) {
out.push({ title: group.title, hint: group.hint, permissions: present });
}
}
if (remaining.size > 0) {
out.push({ title: "Other", hint: "Rights this console version does not have a group for", permissions: [...remaining] });
}
return out;
}

View file

@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/bot-verification")) return "Third-party verification"; if (pathname.startsWith("/bot-verification")) return "Third-party verification";
if (pathname.startsWith("/verification")) return "Official Verification"; if (pathname.startsWith("/verification")) return "Official Verification";
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames"; if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames";
if (pathname.startsWith("/storage")) return "Storage"; if (pathname.startsWith("/storage")) return "Storage";
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices"; if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
if (pathname.startsWith("/accounts")) return "Accounts"; if (pathname.startsWith("/accounts")) return "Accounts";
@ -31,5 +32,6 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/messages")) return "Message Audit"; if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/stickers")) return "Stickers"; if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/gif-catalog")) return "GIFs"; if (pathname.startsWith("/gif-catalog")) return "GIFs";
if (pathname.startsWith("/server-settings")) return "Server Settings";
return "Operations Console"; return "Operations Console";
} }

View file

@ -13,6 +13,11 @@
--panel: #ffffff; --panel: #ffffff;
--panel-subtle: #f7f9fc; --panel-subtle: #f7f9fc;
--panel-strong: #f1f5f9; --panel-strong: #f1f5f9;
/* Loading placeholders. Kept as their own pair rather than reusing the
panel shades because the sheen has to read as lighter than the base in
both themes, and the panel ramp runs the opposite way in dark. */
--skeleton-base: #e6ebf2;
--skeleton-sheen: #f4f7fa;
--surface-soft: #f2f7fd; --surface-soft: #f2f7fd;
--overlay: rgba(24, 34, 47, 0.42); --overlay: rgba(24, 34, 47, 0.42);
--topbar-bg: rgba(255, 255, 255, 0.94); --topbar-bg: rgba(255, 255, 255, 0.94);
@ -100,6 +105,8 @@
--panel: #171f28; --panel: #171f28;
--panel-subtle: #1c2530; --panel-subtle: #1c2530;
--panel-strong: #212c38; --panel-strong: #212c38;
--skeleton-base: #212c38;
--skeleton-sheen: #2e3d4c;
--surface-soft: #1a232d; --surface-soft: #1a232d;
--overlay: rgba(5, 8, 12, 0.62); --overlay: rgba(5, 8, 12, 0.62);
--topbar-bg: rgba(21, 28, 36, 0.86); --topbar-bg: rgba(21, 28, 36, 0.86);
@ -231,6 +238,8 @@ a {
display: grid; display: grid;
width: 34px; width: 34px;
height: 34px; height: 34px;
border-radius: 50%;
overflow: hidden;
place-items: center; place-items: center;
} }
@ -238,7 +247,7 @@ a {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
} }
.brand strong { .brand strong {
@ -254,6 +263,35 @@ a {
font-size: 11px; font-size: 11px;
} }
.sidebar-server-actions {
display: flex;
gap: 8px;
}
.sidebar-server-action {
flex: 1 1 0;
min-width: 0;
font-size: 12.5px;
color: var(--sidebar-text);
background: transparent;
border-color: var(--sidebar-line);
}
.sidebar-server-action:hover:not(:disabled) {
background: var(--sidebar-line);
}
.sidebar-server-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sidebar-server-action-error {
padding: 0 4px;
color: var(--danger, #e5484d);
font-size: 11.5px;
}
.sidebar-label { .sidebar-label {
padding: 0 8px; padding: 0 8px;
color: var(--sidebar-faint); color: var(--sidebar-faint);
@ -263,6 +301,14 @@ a {
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.sidebar-api-layer,
.sidebar-build {
font-weight: 500;
text-transform: none;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
opacity: 0.7;
}
.nav-list { .nav-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@ -436,8 +482,11 @@ a {
.actor-pill { .actor-pill {
display: inline-flex; display: inline-flex;
min-height: 30px; min-height: 30px;
/* Icon and name read as one label rather than two adjacent things. */
gap: 6px;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 12px;
font-weight: 600;
color: var(--text-soft); color: var(--text-soft);
background: var(--panel-subtle); background: var(--panel-subtle);
border: 1px solid var(--line); border: 1px solid var(--line);
@ -457,3 +506,61 @@ a {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
/* Collapsible navigation.
Scoped to the width where the sidebar is actually a sidebar: below 1120px the
shell already stacks it into a horizontal strip (05-responsive.css), and
collapsing that would just hide the labels of a bar that has room for them. */
@media (min-width: 1121px) {
.shell--nav-collapsed {
grid-template-columns: 68px minmax(0, 1fr);
}
/* Everything that only makes sense with room for words goes away: the
wordmark beside the logo, the "Navigation" heading, the version block and
the Connect/Share buttons. The icons and their tooltips remain. */
.shell--nav-collapsed .brand > span:not(.brand-mark),
.shell--nav-collapsed .sidebar-label,
.shell--nav-collapsed .sidebar-status,
.shell--nav-collapsed .sidebar-server-actions,
.shell--nav-collapsed .nav-item-label {
display: none;
}
.shell--nav-collapsed .sidebar {
padding: 18px 10px;
align-items: center;
}
.shell--nav-collapsed .brand {
justify-content: center;
padding: 0;
}
/* One column instead of icon + label, so the glyph sits centred in the rail
rather than clinging to the left edge where the text used to start. */
.shell--nav-collapsed .nav-item {
grid-template-columns: 1fr;
justify-items: center;
padding: 0;
width: 44px;
}
.shell--nav-collapsed .nav-list {
justify-items: center;
}
}
/* The toggle sits with the page title rather than inside the sidebar: it has to
stay reachable at 68px wide, and a control that hides with the thing it
controls is a trap. */
.topbar-lead {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.nav-toggle {
flex: 0 0 auto;
}

View file

@ -111,6 +111,52 @@ button.stat-tile.clickable {
line-height: 1.1; line-height: 1.1;
} }
/* Loading placeholder. Sized in em so it occupies the same box as the text it
stands in for -- the tile must not resize when the real value arrives. */
.skeleton {
display: inline-block;
border-radius: 6px;
background-image: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-sheen) 37%,
var(--skeleton-base) 63%
);
background-size: 400% 100%;
animation: skeletonShimmer 1.4s ease-in-out infinite;
/* Nothing here is real content: keep it out of the accessibility tree and
out of copied text. The live region announcing "loading" belongs on the
container, not on every bar. */
user-select: none;
}
.skeleton-value {
height: 0.85em;
width: 2.75ch;
vertical-align: middle;
}
.skeleton-text {
height: 0.8em;
width: 6ch;
vertical-align: middle;
}
@keyframes skeletonShimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.stat-tile.warn .stat-tile-value { .stat-tile.warn .stat-tile-value {
color: var(--warn); color: var(--warn);
} }
@ -779,6 +825,27 @@ textarea:focus {
font-weight: 800; font-weight: 800;
} }
.sort-header {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.sort-header:hover {
color: var(--text);
}
.sort-header-idle {
opacity: 0.45;
}
.data-table tbody tr:hover { .data-table tbody tr:hover {
background: var(--panel-subtle); background: var(--panel-subtle);
} }
@ -869,3 +936,251 @@ textarea:focus {
} }
} }
/* Operator accounts (AdminUsersPage). The permission picker is a checkbox grid
rather than a role dropdown: the backend stores a permission set, so the
screen shows exactly that set instead of a friendlier abstraction that could
drift from what the routes enforce.
Selectors carry .form-stack because these labels live inside one, and
".form-stack label { display: grid }" would otherwise out-specify a bare
.permission-item and stack the box above its own text. */
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin: 2px 0 8px;
}
.form-stack .permission-item,
.permission-item {
display: flex;
align-items: center;
gap: 8px;
width: auto;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-subtle);
cursor: pointer;
font-size: 12px;
transition: border-color 140ms ease;
}
.permission-item:hover {
border-color: var(--brand-tint-border);
}
/* Explicit box size: the generic "input" rule gives fields a text-input's
padding and .form-stack stretches them to 100%, neither of which suits a
checkbox. */
.form-stack .permission-item input[type="checkbox"],
.permission-item input[type="checkbox"] {
width: 15px;
height: 15px;
flex: 0 0 auto;
margin: 0;
padding: 0;
border-radius: 4px;
accent-color: var(--brand);
cursor: pointer;
}
/* Title over hint. The checkbox stays vertically centred against the pair
rather than against the first line, so a two-line entry does not look
top-heavy. */
.permission-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.permission-copy strong {
overflow: hidden;
color: var(--text);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-copy small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The standalone Enabled toggle is one control, not a grid cell, so it sits at
its natural width instead of stretching across the row. */
.permission-item.standalone {
justify-self: start;
width: max-content;
}
/* A granted permission, listed in the table. It carries the human name now, so
it is set in the UI font -- the raw "content.manage" string stays available
as the chip's tooltip for anyone who needs to match it against the .env. */
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.pill {
display: inline-block;
padding: 2px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
}
.pill.good {
border-color: var(--good-border);
color: var(--good);
}
/* Grouped permission editor. Each group is a labelled block so the twenty-odd
rights read as a few short decisions rather than one undifferentiated run. */
.permission-groups {
display: grid;
gap: 14px;
}
.permission-group {
display: grid;
gap: 8px;
}
.permission-group-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.permission-group-head strong {
display: block;
color: var(--heading);
font-size: 13px;
}
.permission-group-head small {
display: block;
margin-top: 1px;
color: var(--muted);
font-size: 11px;
}
/* Username and password sit side by side above the rights, so the identity
fields do not read as the first permission group. */
.operator-identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
/* The password dialog has one field; the command modal's default width would
leave it stranded in the middle of a mostly empty sheet. */
.modal.narrow {
width: min(460px, 100%);
}
/* Refusal screens (403 / 404). A full-height panel rather than an alert strip
above an empty page: hitting one is the end of that navigation, not a
warning about the page you are on. */
.status-screen {
position: relative;
display: grid;
min-height: min(560px, 70vh);
overflow: hidden;
place-items: center;
padding: 32px 24px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
text-align: center;
}
/* The status code as a watermark. Large enough to be read instantly, faint
enough that the sentence below it is what the eye lands on. */
.status-screen-code {
position: absolute;
top: 50%;
left: 50%;
color: var(--heading);
font-size: clamp(140px, 26vw, 280px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.04em;
opacity: 0.05;
transform: translate(-50%, -50%);
user-select: none;
pointer-events: none;
}
.status-screen-body {
position: relative;
display: grid;
max-width: 460px;
gap: 12px;
justify-items: center;
}
.status-screen-icon {
display: grid;
width: 54px;
height: 54px;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 50%;
}
.status-screen-body h1 {
margin: 0;
color: var(--heading);
font-size: 20px;
line-height: 1.25;
}
.status-screen-body p {
margin: 0;
color: var(--text-soft);
font-size: 13px;
line-height: 1.55;
}
.status-screen-detail {
padding: 5px 10px;
color: var(--text-soft);
background: var(--panel-strong);
border: 1px solid var(--line);
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
overflow-wrap: anywhere;
}
.status-screen-body .btn {
margin-top: 4px;
}

View file

@ -206,6 +206,40 @@
border-top: 0; border-top: 0;
} }
/* A GitHub-Settings-style "Danger Zone" box: a red-bordered card with its own
title strip, wrapping the specific fields/toggles that reach outside plain
per-user data (e.g. StoragePage's Avatar/GIF retention controls, which also
govern the built-in system bot avatars and the bundled GIF catalog) --
distinct from .danger-zone above, which is just a plain action-row divider,
not a bordered card. */
.danger-zone-box {
margin: 4px 0 14px;
border: 1px solid var(--danger-border);
border-radius: var(--radius);
overflow: hidden;
}
.danger-zone-box-title {
padding: 8px 12px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--danger);
background: var(--danger-tint);
border-bottom: 1px solid var(--danger-border);
}
.danger-zone-box-body {
padding: 10px 12px;
display: grid;
gap: 10px;
}
.danger-zone-box-body .env-field-desc {
color: var(--danger-text, var(--danger));
}
.authorization-block { .authorization-block {
display: grid; display: grid;
gap: 10px; gap: 10px;
@ -928,3 +962,467 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; 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 identity card: icon+upload fixed on the left, name+description
filling the rest of the section's width on the right, full-width Save
button spanning the whole section beneath. */
.identity-card {
width: 100%;
}
.identity-layout {
display: flex;
align-items: flex-start;
gap: 24px;
}
.identity-layout .server-identity-fields {
flex: 1 1 auto;
gap: 12px;
}
.identity-layout .form-field textarea {
min-height: 92px;
resize: vertical;
}
.identity-save-row .btn {
width: 100%;
justify-content: center;
min-height: 40px;
}
.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(340px, 100%);
overflow: hidden;
background: var(--panel);
background-image: radial-gradient(220px 140px at 50% 0%, var(--brand-tint) 0%, rgba(0, 0, 0, 0) 75%);
}
/* .restart-overlay-body qualified by its .restart-overlay parent so this
padding/gap wins over the bare .command-body rule in 04-modal-and-login
(equal specificity otherwise, and that file loads after this one). */
.restart-overlay .restart-overlay-body {
display: grid;
justify-items: center;
gap: 6px;
padding: 36px 24px 32px;
text-align: center;
}
.restart-overlay-badge {
display: grid;
width: 56px;
height: 56px;
margin-bottom: 10px;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 999px;
box-shadow: 0 0 0 8px var(--brand-tint);
}
.restart-overlay-badge.warn {
color: var(--warn);
background: var(--warn-tint);
border-color: var(--warn-border);
box-shadow: 0 0 0 8px var(--warn-tint);
}
.restart-overlay-spin {
animation: spin 1.4s linear infinite;
}
.restart-overlay-heading {
margin: 0;
color: var(--heading);
font-size: 18px;
}
.restart-overlay-body p {
margin: 0;
color: var(--muted);
}
.restart-overlay-progress {
width: min(220px, 100%);
margin-top: 18px;
}
.restart-overlay-actions {
justify-content: center;
}
/* Server Settings' Settings/Services tab bar. */
.tab-bar {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: var(--radius);
width: fit-content;
}
.tab-btn {
appearance: none;
border: none;
background: transparent;
color: var(--text-soft);
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
background: var(--panel);
color: var(--text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* Live Docker/process status cards -- Services tab. */
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.service-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.service-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex: none;
border-radius: var(--radius-sm);
background: var(--surface-soft);
color: var(--text-soft);
}
.service-card-body {
flex: 1;
min-width: 0;
}
.service-card-name {
font-weight: 700;
font-size: 13px;
color: var(--text);
text-transform: capitalize;
}
.service-card-detail {
font-size: 11.5px;
color: var(--muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
margin-top: 1px;
}
.service-card-status {
display: flex;
align-items: center;
gap: 5px;
flex: none;
font-size: 12px;
font-weight: 700;
text-transform: capitalize;
padding: 4px 9px;
border-radius: 999px;
}
.service-card.tone-good .service-card-icon { color: var(--good); }
.service-card.tone-good .service-card-status {
color: var(--good);
background: var(--good-tint);
border: 1px solid var(--good-border);
}
.service-card.tone-warn .service-card-icon { color: var(--warn); }
.service-card.tone-warn .service-card-status {
color: var(--warn);
background: var(--warn-tint);
border: 1px solid var(--warn-border);
}
.service-card.tone-danger .service-card-icon { color: var(--danger); }
.service-card.tone-danger .service-card-status {
color: var(--danger);
background: var(--danger-tint);
border: 1px solid var(--danger-border);
}
.service-card.tone-idle .service-card-status {
color: var(--muted);
background: var(--surface-soft);
border: 1px solid var(--line);
}
.services-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* The message itself, rendered the way it was read rather than the way it is
stored. Leads every message detail page; the database rows sit below it in a
folded block. */
.message-view {
display: grid;
gap: 10px;
}
.message-view-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.message-view-head strong {
display: block;
color: var(--heading);
font-size: 14px;
}
.message-view-head small {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
/* Given a bubble's shape on purpose: it is the one element on the page that is
the message rather than a fact about it. */
.message-bubble {
display: grid;
gap: 10px;
max-width: 720px;
padding: 14px 16px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
border-top-left-radius: var(--radius-xs);
}
.message-text {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 14px;
line-height: 1.5;
/* Message text keeps its own line breaks; collapsing them would silently
reformat what was actually sent. */
white-space: pre-wrap;
}
.message-text.empty {
color: var(--muted-2);
font-style: italic;
}
.message-attachment {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.message-attachment-icon {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: var(--radius-sm);
}
.message-attachment-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.message-attachment-copy strong {
color: var(--text);
font-size: 13px;
}
.message-attachment-copy small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-flags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* Folded raw rows. Closed by default so the page opens on the message, not on
three JSON dumps -- still one click away for the times the stored state is
the actual question. */
.raw-details > summary {
padding: 8px 10px;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
font-weight: 700;
list-style: none;
}
.raw-details > summary::-webkit-details-marker {
display: none;
}
.raw-details > summary::before {
content: "▸ ";
color: var(--muted);
}
.raw-details[open] > summary::before {
content: "▾ ";
}
.raw-details[open] > summary {
margin-bottom: 12px;
}

View file

@ -63,6 +63,33 @@
gap: 8px; gap: 8px;
} }
.add-server-link-modal {
width: min(920px, 100%);
max-height: min(880px, calc(100vh - 48px));
}
.add-server-link-field {
display: grid;
gap: 8px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.add-server-link-field textarea {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
word-break: break-all;
resize: vertical;
}
.add-server-link-hint {
margin: 0;
color: var(--muted-2);
font-size: 12.5px;
}
.command-body { .command-body {
display: grid; display: grid;
min-height: 0; min-height: 0;
@ -245,21 +272,25 @@
overflow: hidden; overflow: hidden;
} }
.login-page .bg-orbs { .bg-orbs {
position: absolute; position: absolute;
inset: -60px; inset: -60px;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
/* Eased so the pointer parallax glides instead of snapping to the cursor,
matching the marketing site. will-change keeps it off the main thread. */
transition: transform 0.3s ease-out;
will-change: transform;
} }
.login-page .bg-orb { .bg-orb {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(100px); filter: blur(100px);
pointer-events: none; pointer-events: none;
} }
.login-page .bg-orb--1 { .bg-orb--1 {
top: -15%; top: -15%;
left: -10%; left: -10%;
width: 700px; width: 700px;
@ -268,7 +299,7 @@
animation: loginOrbFloat1 20s ease-in-out infinite; animation: loginOrbFloat1 20s ease-in-out infinite;
} }
.login-page .bg-orb--2 { .bg-orb--2 {
top: 25%; top: 25%;
right: -15%; right: -15%;
width: 600px; width: 600px;
@ -277,7 +308,7 @@
animation: loginOrbFloat2 24s ease-in-out infinite; animation: loginOrbFloat2 24s ease-in-out infinite;
} }
.login-page .bg-orb--3 { .bg-orb--3 {
bottom: -15%; bottom: -15%;
left: 30%; left: 30%;
width: 500px; width: 500px;
@ -303,14 +334,15 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.login-page .bg-orb { filter: blur(60px); } .bg-orb { filter: blur(60px); }
.login-page .bg-orb--1 { width: 350px; height: 350px; } .bg-orb--1 { width: 350px; height: 350px; }
.login-page .bg-orb--2 { width: 300px; height: 300px; } .bg-orb--2 { width: 300px; height: 300px; }
.login-page .bg-orb--3 { width: 250px; height: 250px; } .bg-orb--3 { width: 250px; height: 250px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.login-page .bg-orb { animation: none; } .bg-orb { animation: none; }
.bg-orbs { transition: none; }
} }
.login-page .login-panel { .login-page .login-panel {
@ -320,13 +352,19 @@
.login-panel { .login-panel {
display: grid; display: grid;
width: min(420px, 100%); width: min(480px, 100%);
gap: 18px; gap: 22px;
padding: 22px; padding: 30px;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow); box-shadow: var(--shadow);
/* The rest of the panel (chrome excluded) is scoped up from the admin
panel's normal 13px/dense sizing -- everywhere else on this site is a
data-table console meant to be scanned, but this screen is one form,
seen once per session, at whatever size someone's monitor happens to
be. */
font-size: 15px;
} }
.login-head { .login-head {
@ -344,6 +382,25 @@
gap: 8px; gap: 8px;
} }
.login-head-actions .icon-btn {
width: 36px;
height: 36px;
}
.login-panel .brand-mark {
width: 44px;
height: 44px;
}
.login-panel .brand strong {
font-size: 18px;
}
.login-panel .brand small {
margin-top: 4px;
font-size: 13px;
}
.login-chip { .login-chip {
display: inline-flex; display: inline-flex;
min-height: 24px; min-height: 24px;
@ -356,17 +413,6 @@
font-size: 12px; font-size: 12px;
} }
.login-copy h1 {
margin: 0;
color: var(--heading);
font-size: 22px;
}
.login-copy p {
margin: 8px 0 0;
color: var(--muted);
}
.form-stack { .form-stack {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -381,6 +427,132 @@
width: 100%; width: 100%;
} }
.login-panel .form-stack input {
height: 46px;
padding: 0 14px;
}
.login-panel .btn {
min-height: 44px;
font-size: 15px;
}
/* Login form: username and password are two panels of equal width, slid
horizontally by translateX on the track rather than shown/hidden, so
moving between them reads as one continuous field instead of a page
swap. The clipping .login-wizard is what makes the off-screen step
invisible; aria-hidden + tabIndex={-1} in the component keep it out of
the tab order and screen readers while it's off-screen. */
.login-wizard {
overflow: hidden;
}
.login-wizard-track {
display: flex;
transition: transform 220ms ease;
}
.login-wizard-step {
flex: 0 0 100%;
min-width: 0;
}
/* Back sits beside Log in rather than squeezed against the "Password"
label -- as a normal-height text+icon button it has a real click target
and room to breathe, instead of a 22px icon crowding the label above a
400px-wide field. */
.login-wizard-actions {
display: flex;
gap: 10px;
}
.login-wizard-actions .btn.primary {
flex: 1 1 auto;
}
@media (prefers-reduced-motion: reduce) {
.login-wizard-track {
transition: none;
}
}
/* First-run setup wizard (components/SetupWizard.tsx) -- built on the same
.login-page/.login-panel shell as the sign-in screen, just wider: this one
holds a name+description+icon row and env fields, not two single inputs. */
.setup-wizard-panel {
width: min(640px, 100%);
max-height: min(760px, calc(100vh - 48px));
overflow-y: auto;
}
.wizard-steps {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wizard-steps .command-step {
flex: 1 1 0;
justify-content: center;
min-width: 0;
padding: 0 6px;
font-size: 12.5px;
}
.wizard-steps .command-step strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wizard-step-body {
display: grid;
gap: 14px;
}
.wizard-step-body > p {
margin: 0;
color: var(--text-soft);
}
.wizard-step-body > p.wizard-step-hint {
color: var(--muted);
font-size: 13px;
}
.wizard-step-body > p.wizard-welcome-greeting {
color: var(--heading);
font-size: 24px;
font-weight: 800;
}
.wizard-identity-row {
display: flex;
align-items: flex-start;
gap: 16px;
}
.wizard-identity-row .server-identity-fields {
flex: 1 1 auto;
display: grid;
gap: 10px;
}
.wizard-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 560px) {
.wizard-steps .command-step strong {
display: none;
}
.wizard-identity-row {
flex-direction: column;
}
}
.boot-screen { .boot-screen {
display: grid; display: grid;
min-height: 100vh; min-height: 100vh;
@ -425,3 +597,96 @@
transform: rotate(360deg); 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;
}
/* The drifting icon field behind the sign-in card. Sits above the blurred orbs
and below the panel, so the two background layers read as one scene.
Fills whichever .app-background contains it. */
.bg-icons {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
color: var(--brand-2);
pointer-events: none;
}
.bg-icon {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
/* The background wrapper. On the sign-in screen it fills that screen; behind
the workspace it is pinned to the viewport so it does not scroll away under
a long page, and so the icons -- which bounce off window bounds -- stay
inside the area they are drawn in. */
.app-background {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.app-background--workspace {
position: fixed;
}
/* Quieter behind real content than behind a lone sign-in card: the panel is
dense with tables and numbers, and the background has to stay background. */
.app-background--workspace .bg-icons {
opacity: 0.55;
}
.app-background--workspace .bg-orb {
opacity: 0.6;
}
/* The chrome paints over the background: the sidebar hides the part of it that
would otherwise show through the navigation, which is also why the wrapper
can span the whole viewport instead of hard-coding the sidebar's width.
Only z-index is set here -- the sidebar is already sticky, and restating
position would be a chance to change it by accident. The topbar needs
nothing: it carries z-index 20 of its own, and a rule here would out-specify
its own "position: sticky" and quietly stop it sticking. */
.shell > .sidebar {
z-index: 2;
}
.workspace > .content {
position: relative;
z-index: 1;
}

View file

@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
transfers: CollectibleUsernameTransferRow[] | null; transfers: CollectibleUsernameTransferRow[] | null;
}; };
export type ReservedUsernameRow = {
username: string;
reason: string;
actor: string;
created_at: number;
};
export type ReservedUsernameListResponse = {
reserved: ReservedUsernameRow[] | null;
};
// Official platform verification. Every int64 the backend tags `,string` stays a // Official platform verification. Every int64 the backend tags `,string` stays a
// decimal string here: application ids, peer ids and the optimistic-locking // decimal string here: application ids, peer ids and the optimistic-locking
// version all outgrow the exact range of a JSON number, and a rounded version // version all outgrow the exact range of a JSON number, and a rounded version
@ -580,6 +591,28 @@ export type AdminSession = {
// 404, so this is a UI convenience on top of a real enforcement, not the // 404, so this is a UI convenience on top of a real enforcement, not the
// enforcement itself. // enforcement itself.
hide_third_party_verification?: boolean; hide_third_party_verification?: boolean;
// False until the first-run setup wizard has been finished -- see
// identity.Store.SetupPending. Missing/undefined is treated as true (an
// admin binary older than this field never gates on it), so only an
// explicit false shows the wizard.
setup_completed?: 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;
// Every MTProto TL schema layer this server binary can admit and encode
// for (oldest first) -- the server is multi-layer, so this is the whole
// supported set, not just the newest one. Shown in the sidebar footer
// above the build/commit line.
api_layers?: number[];
// This admin binary's own build -- shown under "Version" in the sidebar
// footer so an operator can tell which build is actually running.
build?: {
commit: string;
short_commit: string;
dirty: boolean;
build_time: string;
};
}; };
export type AdminLoginResult = AdminSession & { export type AdminLoginResult = AdminSession & {
@ -710,7 +743,7 @@ export type SharedDeviceGroupListResponse = {
export type StorageStatsResponse = { export type StorageStatsResponse = {
PhysicalBytes: string; PhysicalBytes: string;
LogicalBytes: string; LogicalBytes: string;
UnattributedBytes: string; SystemBytes: string;
DocumentCount: string; DocumentCount: string;
PhotoCount: string; PhotoCount: string;
AccountCount: string; AccountCount: string;
@ -736,6 +769,11 @@ export type HostStatsSnapshot = {
MemTotalBytes: number; MemTotalBytes: number;
DiskFreeBytes: number; DiskFreeBytes: number;
DiskTotalBytes: number; DiskTotalBytes: number;
// False when the disk-space sample itself failed (wrong path, not
// created yet, etc) -- DiskFreeBytes/DiskTotalBytes are stale/zero in
// that case, not "the disk is actually full". Independent of Ready,
// which only covers CPU/memory.
DiskReady: boolean;
Ready: boolean; Ready: boolean;
}; };
@ -782,9 +820,11 @@ export type BroadcastRow = {
ID: number; ID: number;
Message: string; Message: string;
TargetMode: string; TargetMode: string;
TotalCount: number; TargetCount: number;
MaterializedCount: number;
SentCount: number; SentCount: number;
FailedCount: number; FailedCount: number;
EnumerationDone: boolean;
CreatedBy: string; CreatedBy: string;
CreatedAt: string; CreatedAt: string;
}; };
@ -829,3 +869,94 @@ export type GroupMessageListResponse = {
limit: number; limit: number;
rows: GroupMessageRow[]; rows: GroupMessageRow[];
}; };
export type ServerIdentity = {
name: string;
description: string;
icon_ext?: string;
// welcome_message_*_template: raw admin-panel override for the 777000
// login-notification message, empty when unset (falls back to the
// TELESRV_WELCOME_MESSAGE_*_TEMPLATE env var, then a built-in default).
welcome_message_phone_template?: string;
welcome_message_email_template?: string;
// default_welcome_message_*_template: the effective fallback text this
// admin process currently reads (env var if set, else the compiled-in
// copy) -- shown when the override above is empty.
default_welcome_message_phone_template: string;
default_welcome_message_email_template: string;
// login_code_message_template: raw admin-panel override for the 777000
// login-code delivery message, empty when unset (falls back to the
// TELESRV_LOGIN_CODE_MESSAGE_TEMPLATE env var, then a built-in default).
// Unlike the welcome_message_* templates there is only one -- the
// message never varies by delivery channel. Must contain the {{code}}
// placeholder exactly once (enforced server-side on save).
login_code_message_template?: string;
// default_login_code_message_template: the effective fallback text this
// admin process currently reads -- shown when the override above is empty.
default_login_code_message_template: 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;
};
export type DockerService = {
name: string;
state: string;
health: string;
};
// One admin console operator. Mirrors AdminConsoleUser in adminusers.go; the
// password hash deliberately has no representation here.
export type AdminConsoleUser = {
id: number;
username: string;
permissions: string[];
enabled: boolean;
token_epoch: number;
created_at: string;
updated_at: string;
last_login_at?: string | null;
};
// The built-in operator backed by TELESRV_ADMIN_UI_PASSWORD / _TOKEN. It has no
// database row, so it carries no id and cannot be edited from the panel.
export type AdminConsoleSystemOperator = {
username: string;
permissions: string[];
enabled: boolean;
system: true;
};
export type AdminConsoleUserList = {
system?: AdminConsoleSystemOperator;
rows: AdminConsoleUser[];
// The rights the server is willing to assign, so the editor cannot drift
// from what the routes actually enforce.
available_permissions: string[];
};
// The two branding fields the login screen may read without a session.
export type PublicBranding = {
name: string;
has_icon: boolean;
};

View file

@ -38,6 +38,16 @@ func run(ctx context.Context, args []string) error {
return runKeygen(args[1:]) return runKeygen(args[1:])
case "provision": case "provision":
return runProvision(ctx, args[1:]) return runProvision(ctx, args[1:])
case "plan-dataset":
return runPlanDataset(args[1:])
case "seed":
return runSeed(ctx, args[1:])
case "snapshot":
return runSnapshot(ctx, args[1:])
case "mutate-offline":
return runMutateOffline(ctx, args[1:])
case "startup-run":
return runStartup(ctx, args[1:])
case "run": case "run":
return runLoad(ctx, args[1:]) return runLoad(ctx, args[1:])
case "summarize": case "summarize":
@ -50,6 +60,231 @@ func run(ctx context.Context, args []string) error {
} }
} }
func runPlanDataset(args []string) error {
flags := flag.NewFlagSet("plan-dataset", flag.ContinueOnError)
out := flags.String("out", filepath.FromSlash("data/loadtest/dataset.json"), "owner-only immutable dataset plan")
accounts := flags.Int("accounts", 1000, "logical primary accounts in the provisioned manifest")
seed := flags.Int64("seed", 20260827, "deterministic topology and idempotency seed")
privateFanout := flags.Int("private-fanout", -1, "outgoing private messages per account; -1 uses min(10, accounts-1)")
hotGroups := flags.Int("hot-groups", 10, "hot supergroup count")
hotMembers := flags.Int("hot-members", 0, "members per hot supergroup; 0 uses all accounts")
hotHistory := flags.Int("hot-history", 100, "messages per hot supergroup")
mediumGroups := flags.Int("medium-groups", 100, "medium supergroup count")
mediumMembers := flags.Int("medium-members", 100, "members per medium supergroup")
mediumHistory := flags.Int("medium-history", 30, "messages per medium supergroup")
smallGroups := flags.Int("small-groups", 200, "small supergroup count")
smallMembers := flags.Int("small-members", 20, "members per small supergroup")
smallHistory := flags.Int("small-history", 10, "messages per small supergroup")
heavyGroups := flags.Int("heavy-groups", 200, "heavy-user supergroup count")
heavyAccounts := flags.Int("heavy-accounts", 100, "accounts included in every heavy supergroup")
heavyHistory := flags.Int("heavy-history", 30, "messages per heavy supergroup")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("plan-dataset accepts no positional arguments")
}
if *hotMembers == 0 {
*hotMembers = *accounts
}
if *privateFanout == -1 {
*privateFanout = min(10, max(*accounts-1, 0))
}
cfg := loadharness.DatasetConfig{
Accounts: *accounts, Seed: *seed, PrivateFanout: *privateFanout,
HotGroups: *hotGroups, HotMembers: *hotMembers, HotHistory: *hotHistory,
MediumGroups: *mediumGroups, MediumMembers: min(*mediumMembers, *accounts), MediumHistory: *mediumHistory,
SmallGroups: *smallGroups, SmallMembers: min(*smallMembers, *accounts), SmallHistory: *smallHistory,
HeavyGroups: *heavyGroups, HeavyAccounts: min(*heavyAccounts, *accounts), HeavyHistory: *heavyHistory,
}
if _, err := os.Stat(*out); err == nil {
existing, loadErr := loadharness.LoadDataset(*out)
if loadErr != nil {
return loadErr
}
if existing.Config != cfg {
return fmt.Errorf("refusing to replace existing dataset plan %s with different config", *out)
}
fmt.Fprintf(os.Stdout, "dataset plan already exists at %s hash=%s groups=%d private_messages=%d\n",
*out, existing.PlanSHA256, len(existing.Groups), len(existing.PrivateEdges))
return nil
} else if !os.IsNotExist(err) {
return err
}
dataset, err := loadharness.PlanDataset(cfg)
if err != nil {
return err
}
if err := loadharness.WriteDataset(*out, dataset); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "dataset plan written to %s hash=%s groups=%d private_messages=%d\n",
*out, dataset.PlanSHA256, len(dataset.Groups), len(dataset.PrivateEdges))
return nil
}
func runSeed(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
state := flags.String("state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "resumable seed journal")
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one seed RPC")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("seed accepts no positional arguments")
}
result, err := loadharness.Seed(ctx, loadharness.SeedConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
DatasetPath: *dataset, SeedStatePath: *state, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
}, func(event loadharness.SeedEvent) {
status := "ok"
if event.Err != nil {
status = "error"
}
fmt.Fprintf(os.Stdout, "seed phase=%s %d/%d account=%d status=%s\n",
event.Phase, event.Completed, event.Total, event.Account, status)
})
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "seed complete private_messages=%d supergroups=%d invited_members=%d group_messages=%d rich_state_accounts=%d state=%s\n",
result.PrivateMessages, result.Groups, result.InvitedMembers, result.GroupMessages, result.RichStateAccounts, *state)
return nil
}
func runSnapshot(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("snapshot", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "baseline account/dialog/PTS snapshot")
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one snapshot RPC")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("snapshot accepts no positional arguments")
}
result, err := loadharness.SnapshotClientState(ctx, loadharness.SnapshotConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
Concurrency: *concurrency, OperationTimeout: *operationTimeout,
}, func(event loadharness.SnapshotEvent) {
status := "ok"
if event.Resumed {
status = "resumed"
}
if event.Err != nil {
status = "error"
}
fmt.Fprintf(os.Stdout, "snapshot %d/%d account=%d status=%s\n", event.Completed, event.Total, event.Account, status)
})
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "snapshot complete accounts=%d dialogs=%d channel_dialogs=%d client_state=%s\n",
result.Accounts, result.Dialogs, result.Channels, *clientState)
return nil
}
func runMutateOffline(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("mutate-offline", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "resumable offline mutation journal")
concurrency := flags.Int("concurrency", 8, "parallel writer accounts (max 64)")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one mutation RPC")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("mutate-offline accepts no positional arguments")
}
result, err := loadharness.MutateOffline(ctx, loadharness.MutateOfflineConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
MutationStatePath: *mutationState, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
}, func(event loadharness.MutationEvent) {
status := "ok"
if event.Err != nil {
status = "error"
}
fmt.Fprintf(os.Stdout, "mutate phase=%s %d/%d account=%d status=%s\n",
event.Phase, event.Completed, event.Total, event.Account, status)
})
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "offline mutation complete private_messages=%d dirty_channels=%d channel_messages=%d edited=%d deleted=%d pinned=%d state=%s\n",
result.PrivateMessages, result.DirtyChannels, result.ChannelMessages, result.Edited, result.Deleted, result.Pinned, *mutationState)
return nil
}
func runStartup(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("startup-run", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "completed offline mutation journal")
report := flags.String("report", filepath.FromSlash("data/loadtest/startup-report.json"), "startup correctness and latency report")
events := flags.String("events", filepath.FromSlash("data/loadtest/startup-events.ndjson"), "periodic owner-only startup and server metric evidence")
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
profile := flags.String("profile", loadharness.StartupProfileTDesktopReturningV1, "startup workload: tdesktop-cold-returning-v1 or tdlib-returning-v1")
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "account launch order: shuffled or account-index")
startOrderSeed := flags.Int64("start-order-seed", 0, "deterministic shuffled launch seed; 0 uses the dataset seed")
accounts := flags.Int("accounts", 0, "limit first N accounts; 0 uses the complete dataset")
ramp := flags.Duration("ramp", 30*time.Second, "connection start ramp duration")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one startup RPC")
sampleInterval := flags.Duration("sample-interval", 2*time.Second, "server resource sampling interval")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("startup-run accepts no positional arguments")
}
result, err := loadharness.StartupRun(ctx, loadharness.StartupRunConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
MutationStatePath: *mutationState, ReportPath: *report, EventsPath: *events, ServerMetricsURL: *serverMetrics,
Profile: *profile, StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
AccountLimit: *accounts, RampDuration: *ramp, OperationTimeout: *operationTimeout,
SampleInterval: *sampleInterval,
})
if err != nil {
return err
}
printStartupSummary(result)
if !result.Pass {
return fmt.Errorf("startup acceptance failed; see %s", *report)
}
return nil
}
func printStartupSummary(report *loadharness.StartupRunReport) {
fmt.Fprintf(os.Stdout, "pass=%v business_ready=%d/%d dialogs=%d channel_dialogs=%d account_diff_calls=%d channel_diff_calls=%d channel_full=%d channel_too_long=%d channel_empty=%d\n",
report.Pass, report.BusinessReady, report.ExpectedAccounts, report.DialogsObserved, report.ChannelDialogs,
report.AccountDifference.Calls, report.ChannelDifference.Calls, report.ChannelDifference.Full,
report.ChannelDifference.TooLong, report.ChannelDifference.Empty)
for _, failure := range report.Failures {
fmt.Fprintln(os.Stdout, "failure:", failure)
}
}
func runKeygen(args []string) error { func runKeygen(args []string) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError) flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file") path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
@ -78,7 +313,7 @@ func runProvision(ctx context.Context, args []string) error {
accounts := flags.Int("accounts", 450, "unique accounts") accounts := flags.Int("accounts", 450, "unique accounts")
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session") extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)") concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index") phonePrefix := flags.String("phone-prefix", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix") firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport") obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS") pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
@ -129,12 +364,17 @@ func runLoad(ctx context.Context, args []string) error {
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence") events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest") fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables") serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "session launch order: shuffled or account-index")
startOrderSeed := flags.Int64("start-order-seed", 20260827, "deterministic shuffled launch seed")
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all") sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration") duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation") recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration") ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval") rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables") messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
messageRate := flags.Float64("message-rate", 0, "aggregate fixed arrival rate in messages/second; use with message-interval=-1")
messageQueue := flags.Int("message-queue", 8, "bounded pending sends per primary session for fixed-rate workload")
deliverySettle := flags.Duration("delivery-settle", 10*time.Second, "maximum live-delivery settle time before final updates.getDifference reconciliation")
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval") fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables") fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)") fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
@ -155,8 +395,10 @@ func runLoad(ctx context.Context, args []string) error {
result, err := loadharness.Run(ctx, loadharness.RunConfig{ result, err := loadharness.Run(ctx, loadharness.RunConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride, ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics, ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp, SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval, RPCInterval: *rpcInterval, MessageInterval: *messageInterval, MessageRate: *messageRate,
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout, FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
OperationTimeout: *operationTimeout, OperationTimeout: *operationTimeout,
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor, OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
@ -183,6 +425,25 @@ func runSummarize(args []string) error {
if err != nil { if err != nil {
return err return err
} }
var shape struct {
BusinessReady *int `json:"business_ready"`
}
if err := json.Unmarshal(data, &shape); err != nil {
return err
}
if shape.BusinessReady != nil {
var report loadharness.StartupRunReport
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&report); err != nil {
return err
}
printStartupSummary(&report)
if !report.Pass {
return errors.New("startup report did not pass")
}
return nil
}
var report loadharness.RunReport var report loadharness.RunReport
decoder := json.NewDecoder(strings.NewReader(string(data))) decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
@ -197,9 +458,9 @@ func runSummarize(args []string) error {
} }
func printSummary(report *loadharness.RunReport) { func printSummary(report *loadharness.RunReport) {
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n", fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d scheduled=%d delivered=%d missing=%d\n",
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects, report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
totalFloodWaits(report), report.WorkerFatalErrors) totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
for _, failure := range report.Failures { for _, failure := range report.Failures {
fmt.Fprintln(os.Stdout, "failure:", failure) fmt.Fprintln(os.Stdout, "failure:", failure)
} }
@ -214,12 +475,17 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
} }
func usageError() error { func usageError() error {
return errors.New("expected one of: keygen, provision, run, summarize, help") return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
} }
const usageText = `telesrv-load commands: const usageText = `telesrv-load commands:
keygen generate an owner-only AES-256 session key keygen generate an owner-only AES-256 session key
provision create accounts and encrypted sessions through real MTProto auth provision create accounts and encrypted sessions through real MTProto auth
plan-dataset create an immutable real-data topology with stable RPC identities
seed materialize private dialogs, supergroups and messages via real RPCs
snapshot save paginated real dialogs and old account/channel PTS cursors
mutate-offline create account/channel gaps while preserving the old cursors
startup-run restore old cursors and measure dialogs/difference business readiness
run execute sustained real-client load, offline recovery and reclamation run execute sustained real-client load, offline recovery and reclamation
summarize print the acceptance summary from a JSON report summarize print the acceptance summary from a JSON report

109
cmd/telesrv-update/main.go Normal file
View file

@ -0,0 +1,109 @@
// Command telesrv-update serves native Telegram client update metadata and
// immutable, range-enabled desktop update packages.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
"telesrv/internal/updatecdn"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "telesrv-update:", err)
os.Exit(1)
}
}
func run() error {
listenDefault := envOr("TELESRV_UPDATE_LISTEN", "127.0.0.1:2402")
manifestDefault := envOr("TELESRV_UPDATE_MANIFEST", "data/updates/manifest.json")
filesDefault := envOr("TELESRV_UPDATE_FILES_DIR", "data/updates/files")
listenAddr := flag.String("listen", listenDefault, "HTTP listen address")
manifestPath := flag.String("manifest", manifestDefault, "release manifest path")
filesDir := flag.String("files", filesDefault, "desktop update package directory")
check := flag.Bool("check", false, "validate the catalog and exit")
flag.Parse()
store, err := updatecdn.NewStore(*manifestPath, *filesDir)
if err != nil {
return fmt.Errorf("load update catalog: %w", err)
}
if *check {
fmt.Println("update catalog is valid")
return nil
}
handler, err := updatecdn.NewHandler(store)
if err != nil {
return err
}
listener, err := net.Listen("tcp", *listenAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *listenAddr, err)
}
logger, err := zap.NewProduction()
if err != nil {
_ = listener.Close()
return fmt.Errorf("initialize logger: %w", err)
}
defer logger.Sync() //nolint:errcheck
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 32 << 10,
}
serveErr := make(chan error, 1)
go func() {
serveErr <- server.Serve(listener)
}()
logger.Info("update service started",
zap.String("listen", listener.Addr().String()),
zap.String("manifest", *manifestPath),
zap.String("files", *filesDir))
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serveErr:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
case <-stopCtx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve HTTP: %w", err)
}
logger.Info("update service stopped")
return nil
}
func envOr(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok && value != "" {
return value
}
return fallback
}

View file

@ -10,6 +10,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"runtime" "runtime"
runtimemetrics "runtime/metrics"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@ -60,14 +61,17 @@ import (
"telesrv/internal/app/userprojection" "telesrv/internal/app/userprojection"
"telesrv/internal/app/users" "telesrv/internal/app/users"
verificationapp "telesrv/internal/app/verification" verificationapp "telesrv/internal/app/verification"
welcomemessagesapp "telesrv/internal/app/welcomemessages"
"telesrv/internal/botapi" "telesrv/internal/botapi"
"telesrv/internal/config" "telesrv/internal/config"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/identity"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
obsmetrics "telesrv/internal/observability/metrics" obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/otpdelivery" "telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp" otpsmtp "telesrv/internal/otpdelivery/smtp"
otpwebhook "telesrv/internal/otpdelivery/webhook" otpwebhook "telesrv/internal/otpdelivery/webhook"
"telesrv/internal/procctl"
"telesrv/internal/rpc" "telesrv/internal/rpc"
"telesrv/internal/seed/catalog" "telesrv/internal/seed/catalog"
"telesrv/internal/sfu" "telesrv/internal/sfu"
@ -77,6 +81,7 @@ import (
"telesrv/internal/store/redisstore" "telesrv/internal/store/redisstore"
"telesrv/internal/telegramloginhttp" "telesrv/internal/telegramloginhttp"
"telesrv/internal/turnsrv" "telesrv/internal/turnsrv"
"telesrv/internal/updatecdn"
"telesrv/internal/web" "telesrv/internal/web"
) )
@ -271,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample { func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
var mem runtime.MemStats var mem runtime.MemStats
runtime.ReadMemStats(&mem) runtime.ReadMemStats(&mem)
return []obsmetrics.GaugeSample{ samples := []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())}, {Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)}, {Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)}, {Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)}, {Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
@ -281,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)}, {Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()}, {Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
} }
if value, ok := processCPUSeconds(); ok {
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
}
return samples
}
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
// documentation explicitly warns that CPU-class values are overestimates and
// are not comparable to operating-system process CPU time, so capacity reports
// use telesrv_process_cpu_seconds instead.
func goSchedulerBusySeconds() float64 {
samples := []runtimemetrics.Sample{
{Name: "/cpu/classes/total:cpu-seconds"},
{Name: "/cpu/classes/idle:cpu-seconds"},
}
runtimemetrics.Read(samples)
total := samples[0].Value.Float64()
idle := samples[1].Value.Float64()
if total <= idle {
return 0
}
return total - idle
} }
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample { func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
@ -301,6 +329,15 @@ func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetri
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)}, {Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)}, {Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)}, {Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)}, {Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)}, {Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)}, {Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
@ -443,15 +480,20 @@ type rpcProjectionVerificationNotifier struct {
invalidator interface { invalidator interface {
InvalidateRPCProjectionReadModelForUser(userID int64) InvalidateRPCProjectionReadModelForUser(userID int64)
InvalidateRPCProjectionReadModelForChannel(channelID int64) InvalidateRPCProjectionReadModelForChannel(channelID int64)
InvalidatePeerIdentityReadModel(domain.Peer)
} }
users storepkg.UserCache users storepkg.UserCache
log *zap.Logger peerIdentity bool
log *zap.Logger
} }
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error { func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if n.invalidator == nil { if n.invalidator == nil {
return nil return nil
} }
if n.peerIdentity {
n.invalidator.InvalidatePeerIdentityReadModel(peer)
}
switch peer.Type { switch peer.Type {
case domain.PeerTypeUser: case domain.PeerTypeUser:
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID) n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
@ -508,6 +550,25 @@ func webPagePreviewOption(cfg config.Config) filesapp.Option {
return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin) return filesapp.WithWebPagePreview(cfg.WebPagePreviewMaxBytes, cfg.WebPagePreviewRatePerMin)
} }
// fastestPositiveDuration returns the smaller of a and b, treating a
// non-positive value as "not configured" rather than as the smallest
// possible duration -- 0 only when both are non-positive (nothing
// configured). Used to derive the storage retention sweep's ticker cadence
// from whichever of the shared default age and its per-category overrides
// asks to run soonest.
func fastestPositiveDuration(a, b time.Duration) time.Duration {
if a <= 0 {
return b
}
if b <= 0 {
return a
}
if a < b {
return a
}
return b
}
func run(logger *zap.Logger) error { func run(logger *zap.Logger) error {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@ -568,6 +629,15 @@ func run(logger *zap.Logger) error {
zap.Bool("schema_dirty", migrationStatus.Dirty), zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty), zap.Bool("schema_empty", migrationStatus.Empty),
) )
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("acquire blob runtime lock: %w", err)
}
defer func() {
if err := blobRuntimeLock.Close(); err != nil {
logger.Error("release blob runtime lock", zap.Error(err))
}
}()
pool, err := postgres.Open(ctx, cfg.PostgresDSN, pool, err := postgres.Open(ctx, cfg.PostgresDSN,
postgres.WithMaxConns(cfg.PostgresMaxConns), postgres.WithMaxConns(cfg.PostgresMaxConns),
postgres.WithMinConns(cfg.PostgresMinConns), postgres.WithMinConns(cfg.PostgresMinConns),
@ -647,7 +717,8 @@ func run(logger *zap.Logger) error {
if cfg.TelegramLoginEnabled { if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{ telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
Service: telegramLoginService, Tokens: telegramLoginIDTokens, Service: telegramLoginService, Tokens: telegramLoginIDTokens,
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName, BotUsernames: postgres.NewUserStore(pool),
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs, Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
AllowHTTP: cfg.TelegramLoginAllowHTTP, AllowHTTP: cfg.TelegramLoginAllowHTTP,
}) })
@ -660,51 +731,157 @@ func run(logger *zap.Logger) error {
} }
authKeyStore := postgres.NewAuthKeyStore(pool) authKeyStore := postgres.NewAuthKeyStore(pool)
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
authKeyStore,
postgres.AuthKeyGetBatchConfig{
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeyGetBatchStore.Close()
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
authKeyStore,
postgres.AuthKeySessionLayerBatchConfig{
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeySessionLayerStore.Close()
userStore := postgres.NewUserStore(pool) userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool) authzStore := postgres.NewAuthorizationStore(pool)
adminStore := postgres.NewAdminStore(pool) adminStore := postgres.NewAdminStore(pool)
updateStateStore := postgres.NewUpdateStateStore(pool) updateStateStore := postgres.NewUpdateStateStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates"))) updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
phoneChangeStore := postgres.NewPhoneChangeStore(pool) phoneChangeStore := postgres.NewPhoneChangeStore(pool)
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0) readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
postgres.NewReadModelVersionStore(pool),
storepkg.ReadModelVersionBatchConfig{
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
},
)
if err != nil {
return err
}
defer readModelVersionBatchStore.Close()
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
readModelVersionBatchStore,
0,
cfg.ReadModelVersionCacheMaxEntries,
)
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
postgres.NewBootstrapUpdateJobStore(pool),
postgres.BootstrapReadyBatchConfig{
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer bootstrapUpdateStore.Close()
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool) botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb) botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb) ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool) ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
moderationReportStore := postgres.NewModerationReportStore(pool) moderationReportStore := postgres.NewModerationReportStore(pool)
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool) authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
clientTelemetryStore := postgres.NewClientTelemetryStore(pool) clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool)) reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0) postgres.NewContactStore(pool),
storepkg.ReverseContactBatchConfig{
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
},
)
if err != nil {
return err
}
defer reverseContactStore.Close()
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
reverseContactStore,
0,
cfg.ContactSnapshotCacheMaxViewers,
)
dialogStore := postgres.NewDialogStore(pool) dialogStore := postgres.NewDialogStore(pool)
chatlistStore := postgres.NewChatlistStore(pool) chatlistStore := postgres.NewChatlistStore(pool)
messageStore := postgres.NewMessageStore(pool, messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator), postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages"))) postgres.WithMessageLogger(logger.Named("store").Named("messages")))
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC // 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。 // 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries) channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries) channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries) channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
cfg.ChannelDifferenceCacheMaxEntries,
cfg.ChannelDifferenceCacheMaxBytes,
cfg.ChannelDifferenceCacheTTL,
)
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL) channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
channelStore := postgres.NewChannelStore(pool, channelStore := postgres.NewChannelStore(pool,
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator), postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
postgres.WithChannelLogger(logger.Named("store").Named("channels")), postgres.WithChannelLogger(logger.Named("store").Named("channels")),
postgres.WithChannelRowCache(channelRowCache), postgres.WithChannelRowCache(channelRowCache),
postgres.WithChannelTopMessageCache(channelTopMessageCache),
postgres.WithChannelMemberCache(channelMemberCache), postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache), postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
postgres.WithChannelBoostCache(channelBoostCache)) postgres.WithChannelBoostCache(channelBoostCache))
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator) activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
channelStore,
postgres.ActiveChannelIDsBatchConfig{
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer activeChannelIDsPageBatcher.Close()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
snapshot := channelDifferenceCache.Snapshot()
return []obsmetrics.GaugeSample{
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
}
})
communityCatalogCache := postgres.NewCommunityCatalogCache()
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
postgres.WithCommunityCatalogCache(communityCatalogCache))
pollStore := postgres.NewPollStore(pool) pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool) mediaStore := postgres.NewMediaStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC // 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。 // 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL) cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
mediaStore,
cfg.ProfilePhotoCacheTTL,
cfg.ProfilePhotoCacheMaxEntries,
)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0) privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool) storyStore := postgres.NewStoryStore(pool)
// Transient upload-part scratch storage always stays on local disk // Transient upload-part scratch storage always stays on local disk
@ -774,6 +951,10 @@ func run(logger *zap.Logger) error {
filesapp.WithUploadPartBackend(localBlobFS), filesapp.WithUploadPartBackend(localBlobFS),
filesapp.WithAdditionalBlobBackend(additionalBlobBackend), filesapp.WithAdditionalBlobBackend(additionalBlobBackend),
filesapp.WithSpaceGuard(spaceGuard), filesapp.WithSpaceGuard(spaceGuard),
filesapp.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
filesapp.WithStorageRetentionAges(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeByCategory, cfg.StorageRetentionMaxAgeAvatar),
filesapp.WithStorageMaxTotalBytes(cfg.StorageMaxTotalBytes),
filesapp.WithSecretChatDeleteFileAfterDownload(cfg.SecretChatDeleteFileAfterDownload),
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir), filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
externalMediaOption(cfg), externalMediaOption(cfg),
webPagePreviewOption(cfg), webPagePreviewOption(cfg),
@ -828,10 +1009,25 @@ func run(logger *zap.Logger) error {
zap.Int("blobs", stats.Blobs), zap.Int("blobs", stats.Blobs),
) )
} }
if seeded, err := filesService.SeedOfficialSystemAvatar(ctx); err != nil { // The official system account (777000) mirrors the operator's own Server
// Settings -> Server identity, when set: same "default unless
// configured" contract as the client-facing /owpengram/server-info
// endpoint, just applied to this one built-in account's display name
// and avatar instead of what's shown to a client adding the server.
identityStore := identity.NewStore(cfg.IdentityDir)
serverIdentity, err := identityStore.Get()
if err != nil {
return fmt.Errorf("read server identity: %w", err)
}
domain.SetOfficialSystemUserDisplayName(serverIdentity.Name)
var customSystemIcon []byte
if iconData, _, ok := identityStore.Icon(); ok {
customSystemIcon = iconData
}
if usingCustom, err := filesService.SeedOfficialSystemAvatar(ctx, customSystemIcon); err != nil {
return fmt.Errorf("seed official system avatar: %w", err) return fmt.Errorf("seed official system avatar: %w", err)
} else if seeded { } else if usingCustom {
logger.Info("official system account avatar seed import complete", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID)) logger.Info("official system account avatar seed import complete (custom Server identity icon)", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
} }
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil { if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
return fmt.Errorf("seed botfather avatar: %w", err) return fmt.Errorf("seed botfather avatar: %w", err)
@ -885,11 +1081,12 @@ func run(logger *zap.Logger) error {
Commands: adminStore, Commands: adminStore,
Restrictions: adminStore, Restrictions: adminStore,
}) })
storageRetentionMaxAge := cfg.StorageRetentionMaxAge userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
if !cfg.StorageRetentionEnable { adminService,
storageRetentionMaxAge = 0 readModelVersionStore,
} cfg.UserProjectionFactCacheMaxEntries,
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"), )
retentionWorker := maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
cfg.UpdateEventRetention, cfg.UpdateEventRetention,
cfg.RetentionInterval, cfg.RetentionInterval,
cfg.RetentionBatch, cfg.RetentionBatch,
@ -902,9 +1099,39 @@ func run(logger *zap.Logger) error {
WithModerationRetention(moderationReportStore). WithModerationRetention(moderationReportStore).
WithUserUpdateRetention(updateEventStore). WithUserUpdateRetention(updateEventStore).
WithChannelUpdateRetention(channelStore). WithChannelUpdateRetention(channelStore).
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention). WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention)
WithOrphanedMediaRetention(filesService, storageRetentionMaxAge). // TELESRV_STORAGE_RETENTION_MODE is a single 3-way switch: at most one of
Run(ctx) // the orphan-only (safe) and hard (age-based, ignores live references)
// media sweeps is ever wired in, matching "off"/"orphan"/"hard".
// The worker's own maxAge parameter only drives how often the sweep
// ticks (internal/app/maintenance.RetentionWorker.mediaRetentionInterval)
// -- the real per-category cutoff math lives entirely in
// files.Service (WithStorageRetentionAges above). Passing the raw shared
// default here would tick as slowly as a 30-day default even when a
// TELESRV_STORAGE_RETENTION_MAX_AGE_<CATEGORY> override asks for a much
// shorter age (or, if the shared default is 0 -- "disabled by default,
// only specific categories opt in" -- would disable the sweep outright,
// since a 0 maxAge here used to gate the whole sweep off). Use the
// fastest positive age across the shared default and every override
// instead, so the ticker -- and the sweep-enabled gate -- reflect
// whatever is actually configured to run soonest.
fastestRetentionAge := fastestPositiveDuration(cfg.StorageRetentionMaxAge, cfg.StorageRetentionMaxAgeAvatar)
for _, age := range cfg.StorageRetentionMaxAgeByCategory {
fastestRetentionAge = fastestPositiveDuration(fastestRetentionAge, age)
}
switch cfg.StorageRetentionMode {
case config.StorageRetentionModeOrphan:
retentionWorker = retentionWorker.WithOrphanedMediaRetention(filesService, fastestRetentionAge)
case config.StorageRetentionModeHard:
retentionWorker = retentionWorker.WithHardMediaRetention(filesService, fastestRetentionAge)
}
// Active eviction is independent of TELESRV_STORAGE_RETENTION_MODE (can
// run even when that's "off") and reuses the same media sweep ticker.
retentionWorker = retentionWorker.WithStorageEviction(filesService, cfg.StorageEvictionEnable)
// retentionWorker.Run itself isn't started here -- see the
// filesService.SetRetentionPurgeNotifier call below, which must happen
// first so the worker's very first (synchronous) sweep tick can't purge
// blobs before there's anywhere to send the purge notice.
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"), go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
cfg.UploadPartTTL, cfg.UploadPartTTL,
cfg.UploadPartGCInterval, cfg.UploadPartGCInterval,
@ -915,7 +1142,7 @@ func run(logger *zap.Logger) error {
contactsService := contacts.NewService(contactStore, userStore).Configure( contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos), contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService), contacts.WithPrivacyEvaluator(privacyService),
contacts.WithAccountFreezeProvider(adminService), contacts.WithAccountFreezeProvider(userProjectionFacts),
contacts.WithReadModelVersions(readModelVersionStore), contacts.WithReadModelVersions(readModelVersionStore),
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
) )
@ -1007,18 +1234,48 @@ func run(logger *zap.Logger) error {
account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength)) account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength))
} }
accountService := account.NewService(passwordStore, accountOptions...) accountService := account.NewService(passwordStore, accountOptions...)
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
botsService := botsapp.NewService(userStore, botStore, messageStore, botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithLogger(logger.Named("bots")), botsapp.WithLogger(logger.Named("bots")),
botsapp.WithBlockChecker(contactStore), botsapp.WithBlockChecker(contactStore),
botsapp.WithPublicChannelUsernameResolver(channelStore), botsapp.WithPublicChannelUsernameResolver(channelStore),
botsapp.WithReservedUsernames(reservedUsernameStore),
botsapp.WithUserCache(userCache), botsapp.WithUserCache(userCache),
botsapp.WithStickerSetCreator(filesService), botsapp.WithStickerSetCreator(filesService),
botsapp.WithGifCatalogSource(filesService), botsapp.WithGifCatalogSource(filesService),
botsapp.WithBotAvatarStore(filesService),
botsapp.WithUserStickerSets(accountService), botsapp.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService), botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow), botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL), botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification)) botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
// The built-in ChatBot and StickersBot are seeded with the default product
// name in their bio (users.about) and description (bots.description). Align
// them with the active branding on startup so the seeded "telesrv" text is
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
// already matches.
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
var wantAbout, wantDesc string
switch botID {
case domain.ChatBotUserID:
wantAbout = domain.ChatBotDescription()
wantDesc = wantAbout
case domain.StickersBotUserID:
wantAbout = domain.StickersBotDescription()
wantDesc = wantAbout
}
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
continue
}
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
SetAbout: true,
About: wantAbout,
SetDescription: true,
Description: wantDesc,
}); err != nil {
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
}
}
groupCallStore := postgres.NewGroupCallStore(pool) groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL)) groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
// 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活 // 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活
@ -1103,7 +1360,7 @@ func run(logger *zap.Logger) error {
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。 // 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
secretChatStore := postgres.NewSecretChatStore(pool) secretChatStore := postgres.NewSecretChatStore(pool)
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool) encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator) secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token // Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。 // 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool) passkeyStore := postgres.NewPasskeyStore(pool)
@ -1112,7 +1369,7 @@ func run(logger *zap.Logger) error {
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins)) passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。 // 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool)) themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification)) usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
privacyService.ConfigureReadModels(usersService, channelStore) privacyService.ConfigureReadModels(usersService, channelStore)
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...) aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService) botsService.SetAIChatGenerator(aiComposeService)
@ -1120,9 +1377,21 @@ func run(logger *zap.Logger) error {
dialogs.WithContactStore(contactStore), dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos), dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService), dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithAccountFreezeProvider(adminService), dialogs.WithAccountFreezeProvider(userProjectionFacts),
dialogs.WithPremiumChecker(usersService.PremiumActive), dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore), dialogs.WithReadModelVersions(readModelVersionStore),
dialogs.WithDialogHydrationCaches(
cfg.DialogPrivatePeerCacheMaxEntries,
cfg.DialogPrivatePeerCacheMaxBytes,
cfg.DialogDraftCacheMaxEntries,
cfg.DialogDraftCacheMaxBytes,
),
dialogs.WithDialogListSnapshotCache(
cfg.DialogListSnapshotCacheMaxEntries,
cfg.DialogListSnapshotCacheMaxHeaders,
cfg.DialogListSnapshotCacheTTL,
),
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
) )
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在 // 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。 // 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
@ -1130,10 +1399,19 @@ func run(logger *zap.Logger) error {
channelsService := channelapp.NewService(channelStore, channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService), channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore), channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithActiveChannelIDsReadModel(
activeChannelIDsPageCache,
activeChannelIDsPageBatcher,
cfg.ActiveChannelIDsCacheMaxEntries,
cfg.ActiveChannelIDsCacheTTL,
metricRegistry,
),
channelapp.WithSendPermissionChecker(adminService), channelapp.WithSendPermissionChecker(adminService),
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
) )
communitiesService := communitiesapp.NewService(communityStore) communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService) ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)) storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
chatlistsService := chatlistsapp.NewService( chatlistsService := chatlistsapp.NewService(
chatlistStore, chatlistStore,
@ -1146,12 +1424,23 @@ func run(logger *zap.Logger) error {
messageapp.WithContactStore(contactStore), messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos), messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService), messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithAccountFreezeProvider(adminService), messageapp.WithAccountFreezeProvider(userProjectionFacts),
messageapp.WithReadModelVersions(readModelVersionStore), messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService), messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService), messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...), messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
) )
// Wires the storage retention sweep's purge-notice capability now that
// both edit-capable app services exist -- filesService was constructed
// earlier, before either was available. Must happen before
// retentionWorker.Run starts below: that call's first sweep tick runs
// synchronously (maintenance.RetentionWorker.Run -> runOnce), and once a
// document/photo's blob bytes are purged it never again matches the
// hard-retention candidate query (see ListDocumentIDsForHardRetentionOlderThan's
// doc comment) -- so a tick that raced ahead of this call wouldn't just
// delay the notice, it would permanently lose it (files.SetRetentionPurgeNotifier).
filesService.SetRetentionPurgeNotifier(messagesService, channelsService)
go retentionWorker.Run(ctx)
moderationService := moderationapp.NewService( moderationService := moderationapp.NewService(
moderationReportStore, moderationReportStore,
moderationapp.WithMessageReaders(messagesService, channelsService), moderationapp.WithMessageReaders(messagesService, channelsService),
@ -1173,8 +1462,10 @@ func run(logger *zap.Logger) error {
dialogStore, dialogStore,
newTranslationOptions(cfg, rateLimiter, logger)..., newTranslationOptions(cfg, rateLimiter, logger)...,
) )
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore), auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginWelcomeMessages(identityStore, cfg.WelcomeMessagePhoneTemplate, cfg.WelcomeMessageEmailTemplate),
auth.WithLoginCodeMessageTemplate(identityStore, cfg.LoginCodeMessageTemplate),
auth.WithLoginCodeDelivery(messageStore), auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore), auth.WithPasswords(passwordStore),
auth.WithBotLogin(botStore), auth.WithBotLogin(botStore),
@ -1267,6 +1558,14 @@ func run(logger *zap.Logger) error {
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID)) logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
} }
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates"))) updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
var appUpdateResolver updatecdn.Resolver
if cfg.UpdateServiceURL != "" {
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
if err != nil {
return fmt.Errorf("initialize update service client: %w", err)
}
appUpdateResolver = client
}
router := rpc.New(rpc.Config{ router := rpc.New(rpc.Config{
DC: cfg.DC, DC: cfg.DC,
DefaultCountryCode: cfg.DefaultCountryCode, DefaultCountryCode: cfg.DefaultCountryCode,
@ -1286,92 +1585,109 @@ func run(logger *zap.Logger) error {
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants, GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
RtmpIngestURL: cfg.LiveStreamRtmpURL, RtmpIngestURL: cfg.LiveStreamRtmpURL,
PublicBaseURL: cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
UpdatePublicURL: cfg.UpdatePublicURL,
PublicAppScheme: cfg.PublicAppScheme, PublicAppScheme: cfg.PublicAppScheme,
PublicAppLinkBase: cfg.PublicAppLinkBase, PublicAppLinkBase: cfg.PublicAppLinkBase,
// PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效; // PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效;
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。 // 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL, TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries, TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
}, rpc.Deps{ }, rpc.Deps{
Auth: authService, Auth: authService,
AuthDeliveryReports: authDeliveryReportService, AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService, ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore, AuthKeySessionLayers: authKeySessionLayerStore,
ReadModelVersions: readModelVersionStore,
UserProjectionFacts: userProjectionFacts,
Account: accountService, Account: accountService,
Privacy: privacyService, Privacy: privacyService,
Help: help.NewService(helpStore, helpStore, Help: help.NewService(helpStore, helpStore,
help.WithMapboxToken(cfg.MapboxToken), help.WithMapboxToken(cfg.MapboxToken),
help.WithEmailSignupEnable(cfg.EmailSignupEnable), help.WithEmailSignupEnable(cfg.EmailSignupEnable),
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes), help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
help.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
help.WithAccountFreezeProvider(adminService), help.WithAccountFreezeProvider(adminService),
), ),
AccountFreeze: adminService, AppUpdates: appUpdateResolver,
AICompose: aiComposeService, AccountFreeze: userProjectionFacts,
Ephemeral: ephemeralService, AccountFreezeNotifications: adminService,
EphemeralPush: ephemeralStore, AICompose: aiComposeService,
Moderation: moderationService, Ephemeral: ephemeralService,
Users: usersService, EphemeralPush: ephemeralStore,
Usernames: usernamesService, WelcomeMessages: welcomeMessageService,
BotVerifications: botVerificationService, Moderation: moderationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService), Users: usersService,
Updates: updatesService, Usernames: usernamesService,
BootstrapUpdates: bootstrapUpdateStore, BotVerifications: botVerificationService,
BotAPIUpdates: botAPIUpdateStore, TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
BotCallbacks: botCallbackStore, Updates: updatesService,
Contacts: contactsService, BootstrapUpdates: bootstrapUpdateStore,
Dialogs: dialogsService, BotAPIUpdates: botAPIUpdateStore,
Chatlists: chatlistsService, BotCallbacks: botCallbackStore,
Messages: messagesService, Contacts: contactsService,
Translation: translationService, Dialogs: dialogsService,
Channels: channelsService, Chatlists: chatlistsService,
Communities: communitiesService, Messages: messagesService,
Files: filesService, Translation: translationService,
PremiumPromo: filesService, Channels: channelsService,
Bots: botsService, Communities: communitiesService,
ServiceBotCallbacks: botsService, Files: filesService,
ServiceBotInlineResults: botsService, PremiumPromo: filesService,
Polls: pollsapp.NewService(pollStore), Bots: botsService,
Stories: storiesService, ServiceBotCallbacks: botsService,
Phone: phoneService, ServiceBotInlineResults: botsService,
SecretChats: secretChatService, Polls: pollsapp.NewService(pollStore),
Passkey: passkeyService, Stories: storiesService,
Themes: themeService, Phone: phoneService,
GroupCalls: groupCallsService, SecretChats: secretChatService,
LiveStreams: liveStreamDep(liveStreamService), Passkey: passkeyService,
SFU: sfuService, Themes: themeService,
TURN: turnService, GroupCalls: groupCallsService,
LangPack: langPackService, LiveStreams: liveStreamDep(liveStreamService),
Sessions: activeSessions, SFU: sfuService,
Metrics: metricRegistry, TURN: turnService,
Inline: inlineRegistryStore, LangPack: langPackService,
Limiter: rateLimiter, Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System) }, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{ readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore, ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache, ChannelRows: channelRowCache,
ChannelMembers: channelMemberCache, ChannelTopMessages: channelTopMessageCache,
ChannelDialogs: channelDialogCache, CommunityCatalog: communityCatalogCache,
ChannelBoosts: channelBoostCache, ChannelMembers: channelMemberCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService}, ChannelDialogs: channelDialogCache,
Dialogs: dialogsService, ChannelDifferences: channelDifferenceCache,
Privacy: privacyService, ChannelBoosts: channelBoostCache,
ProfilePhotos: cachedPhotos, Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Stories: router, Dialogs: dialogsService,
ChannelFullBots: router, Privacy: privacyService,
ChannelBotMembers: channelsService, ProfilePhotos: cachedPhotos,
ChannelMediaCounts: channelsService, Stories: router,
PrivateMediaCounts: messagesService, ChannelFullBots: router,
RPCProjections: router, ChannelBotMembers: channelsService,
BaseUsers: userCache, ChannelMediaCounts: channelsService,
BotProfiles: botsService, PrivateMediaCounts: messagesService,
AccountSettings: router, RPCProjections: router,
PeerIdentities: router,
BaseUsers: userCache,
BotProfiles: botsService,
AccountSettings: router,
UserProjectionFacts: userProjectionFacts,
}, logger.Named("store").Named("read-model-listener")) }, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx) go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router) activeSessions.SetLifecycleObserver(router)
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
adminService.Configure(adminapp.Dependencies{ adminService.Configure(adminapp.Dependencies{
Auth: authService, Auth: authService,
Revoker: router, Revoker: router,
@ -1385,10 +1701,12 @@ func run(logger *zap.Logger) error {
Photos: filesService, Photos: filesService,
StickerSets: filesService, StickerSets: filesService,
GifCatalog: filesService, GifCatalog: filesService,
Storage: filesService,
Bots: botsService, Bots: botsService,
Emoji: filesService, Emoji: filesService,
Moderation: moderationService, Moderation: moderationService,
Usernames: usernamesService, Usernames: usernamesService,
ReservedUsernames: reservedUsernameStore,
Verification: verificationService, Verification: verificationService,
BotVerification: botVerificationService, BotVerification: botVerificationService,
Account: accountService, Account: accountService,
@ -1435,9 +1753,10 @@ func run(logger *zap.Logger) error {
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok { if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{ botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
cache: rpcProjectionVerificationNotifier{ cache: rpcProjectionVerificationNotifier{
invalidator: router, invalidator: router,
users: userCache, users: userCache,
log: verificationLogger, peerIdentity: true,
log: verificationLogger,
}, },
edge: notifier, edge: notifier,
}) })
@ -1454,9 +1773,15 @@ func run(logger *zap.Logger) error {
// all/selected users) are delivered from the same kind of durable outbox as // all/selected users) are delivered from the same kind of durable outbox as
// applicant notifications above: an admin creating one for every user must // applicant notifications above: an admin creating one for every user must
// not wait on however long sending to all of them takes. // not wait on however long sending to all of them takes.
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"), go broadcastapp.NewWorker(broadcastService, broadcastapp.WorkerConfig{
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx) Interval: cfg.BroadcastWorkerInterval,
moderationActionOptions := []moderationapp.ActionExecutorOption{} Lease: cfg.BroadcastWorkerLease,
MaterializeBatch: cfg.BroadcastWorkerMaterializeBatch,
DeliveryBatch: cfg.BroadcastWorkerBatch,
}, logger.Named("broadcast").Named("delivery")).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{
moderationapp.WithAccountDeletionNotifier(router),
}
if cfg.PublicLinkWebAddr != "" { if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append( moderationActionOptions = append(
moderationActionOptions, moderationActionOptions,
@ -1485,6 +1810,7 @@ func run(logger *zap.Logger) error {
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates), rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx) ).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx) go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx) go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx) go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx) go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
@ -1492,6 +1818,7 @@ func run(logger *zap.Logger) error {
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx) go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
go router.RunChannelFanout(ctx) go router.RunChannelFanout(ctx)
go router.RunBotAPIEnqueue(ctx) go router.RunBotAPIEnqueue(ctx)
go router.RunPresenceLastSeenBatch(ctx)
go router.RunPresenceSweeper(ctx, time.Minute) go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute) go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
@ -1547,8 +1874,9 @@ func run(logger *zap.Logger) error {
DC: cfg.DC, DC: cfg.DC,
StrictDC: cfg.StrictDCCheck, StrictDC: cfg.StrictDCCheck,
RSAKey: rsaKey, RSAKey: rsaKey,
IdentityDir: cfg.IdentityDir,
LayerRPC: router, LayerRPC: router,
AuthKeys: authKeyStore, AuthKeys: authKeyGetBatchStore,
ActiveSessions: activeSessions, ActiveSessions: activeSessions,
Metrics: metricRegistry, Metrics: metricRegistry,
ObfuscatedTCP: true, ObfuscatedTCP: true,
@ -1563,6 +1891,8 @@ func run(logger *zap.Logger) error {
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers, RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks, RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes, RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries, RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries, RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries, RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
@ -1581,6 +1911,23 @@ func run(logger *zap.Logger) error {
zap.Uint("schema_version", migrationStatus.Version), zap.Uint("schema_version", migrationStatus.Version),
zap.String("blob_backend", "localfs"), zap.String("blob_backend", "localfs"),
) )
// Picks up a pending "please bounce the admin panel" request left
// by cmd/telesrv-admin's Restart/Update (internal/procctl) --
// see HandlePendingAdminRestart's doc comment for why this
// process (the new one, already up) is the safe place to do
// that from, not the admin panel doing it to itself. Repo root
// is cwd, matching cmd/telesrv-admin's own convention; a no-op,
// not an error, when no restart was requested or the repo
// layout (bin/, .server_panel.json) isn't present.
go func() {
if root, err := os.Getwd(); err == nil {
if restarted, err := procctl.NewManager(root).HandlePendingAdminRestart(ctx); err != nil {
logger.Warn("admin panel auto-restart failed", zap.Error(err))
} else if restarted {
logger.Info("admin panel restarted after server restart/update")
}
}
}()
}, },
}) })
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample { metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {

View file

@ -2,10 +2,43 @@ package main
import ( import (
"testing" "testing"
"time"
telegramloginapp "telesrv/internal/app/telegramlogin" telegramloginapp "telesrv/internal/app/telegramlogin"
) )
// TestFastestPositiveDurationIgnoresNonPositiveValues guards a real reported
// bug: the storage retention sweep's ticker cadence used to be driven purely
// by the shared TELESRV_STORAGE_RETENTION_MAX_AGE default (e.g. 30 days),
// even when a much shorter TELESRV_STORAGE_RETENTION_MAX_AGE_<CATEGORY>
// override was configured -- so a category set to "1 minute" would still
// only actually get swept on the shared default's own slow cadence (or, if
// the shared default was 0, would disable the sweep outright). The worker
// must be handed the fastest positive age across the default and every
// override instead.
func TestFastestPositiveDurationIgnoresNonPositiveValues(t *testing.T) {
cases := []struct {
name string
a, b time.Duration
want time.Duration
}{
{"both positive, a smaller", 30 * 24 * time.Hour, time.Minute, time.Minute},
{"both positive, b smaller", time.Minute, 30 * 24 * time.Hour, time.Minute},
{"a zero (disabled), b positive", 0, time.Minute, time.Minute},
{"a positive, b zero (disabled)", time.Minute, 0, time.Minute},
{"a negative, b positive", -time.Hour, time.Minute, time.Minute},
{"both zero (nothing configured)", 0, 0, 0},
{"both negative", -time.Hour, -time.Minute, -time.Minute},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := fastestPositiveDuration(c.a, c.b); got != c.want {
t.Fatalf("fastestPositiveDuration(%v, %v) = %v, want %v", c.a, c.b, got, c.want)
}
})
}
}
func TestTelegramLoginRPCDependencyPreservesDisabledNil(t *testing.T) { func TestTelegramLoginRPCDependencyPreservesDisabledNil(t *testing.T) {
var disabled *telegramloginapp.Service var disabled *telegramloginapp.Service
if dependency := telegramLoginRPCDependency(disabled); dependency != nil { if dependency := telegramLoginRPCDependency(disabled); dependency != nil {

View file

@ -0,0 +1,7 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
package main
func processCPUSeconds() (float64, bool) {
return 0, false
}

View file

@ -0,0 +1,12 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
package main
import "testing"
func TestProcessCPUSecondsAvailable(t *testing.T) {
seconds, ok := processCPUSeconds()
if !ok || seconds < 0 {
t.Fatalf("process CPU seconds = %v, available=%v", seconds, ok)
}
}

View file

@ -0,0 +1,17 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package main
import "golang.org/x/sys/unix"
func processCPUSeconds() (float64, bool) {
var usage unix.Rusage
if err := unix.Getrusage(unix.RUSAGE_SELF, &usage); err != nil {
return 0, false
}
ns := unix.TimevalToNsec(usage.Utime) + unix.TimevalToNsec(usage.Stime)
if ns < 0 {
return 0, false
}
return float64(ns) / 1e9, true
}

View file

@ -0,0 +1,21 @@
//go:build windows
package main
import "golang.org/x/sys/windows"
func processCPUSeconds() (float64, bool) {
handle, err := windows.GetCurrentProcess()
if err != nil {
return 0, false
}
var creation, exit, kernel, user windows.Filetime
if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil {
return 0, false
}
ns := kernel.Nanoseconds() + user.Nanoseconds()
if ns < 0 {
return 0, false
}
return float64(ns) / 1e9, true
}

View file

@ -70,7 +70,12 @@ services:
# 桶由 telesrv 自己在启动时按需创建internal/app/files/blobs3.go 的 NewS3FS # 桶由 telesrv 自己在启动时按需创建internal/app/files/blobs3.go 的 NewS3FS
# 这里不需要额外的 mc 初始化容器。控制台9001仅用于本地调试查看已存对象。 # 这里不需要额外的 mc 初始化容器。控制台9001仅用于本地调试查看已存对象。
minio: minio:
image: minio/minio:latest # quay.io, not Docker Hub: MinIO stopped serving minio/minio to anonymous
# pulls there, so `docker compose up` failed on a fresh machine with
# "pull access denied ... or may require 'docker login'" -- for every tag,
# not just latest. quay.io/minio/minio is MinIO's own registry and needs
# no login.
image: quay.io/minio/minio:latest
container_name: ${TELESRV_DOCKER_PREFIX:-owpengram}-minio container_name: ${TELESRV_DOCKER_PREFIX:-owpengram}-minio
command: ["server", "/data", "--console-address", ":9001"] command: ["server", "/data", "--console-address", ":9001"]
environment: environment:

100
deploy/docker/.env.example Normal file
View file

@ -0,0 +1,100 @@
# Generated automatically by scripts/new-docker-env.*. Keep the resulting .env
# private and never commit it.
COMPOSE_PROJECT_NAME=gramsrv-main
TELESRV_DEPLOYMENT_PROFILE=main-monolith-v1
TELESRV_IMAGE_PREFIX=ghcr.io/iamxvbaba/gramsrv
TELESRV_IMAGE_TAG=main
TELESRV_SERVER_BUILD_TARGET=server-test
TELESRV_LOG_LEVEL=info
# Build provenance for local builds.
TELESRV_BUILD_COMMIT=unknown
TELESRV_BUILD_BRANCH=main
TELESRV_BUILD_TREE_STATE=unknown
TELESRV_BUILD_DATE=unknown
# The host-network server and admin reach these dependencies through loopback.
POSTGRES_DB=telesrv_main
POSTGRES_USER=telesrv
POSTGRES_PASSWORD=CHANGEME
TELESRV_POSTGRES_HOST_PORT=15432
TELESRV_POSTGRES_DSN=postgres://telesrv:CHANGEME@127.0.0.1:15432/telesrv_main?sslmode=disable
TELESRV_REDIS_HOST_PORT=16379
TELESRV_REDIS_ADDR=127.0.0.1:16379
TELESRV_REDIS_PASSWORD=CHANGEME
# Independent random values generated for each deployment.
TELESRV_ADMIN_API_TOKEN=CHANGEME
TELESRV_ADMIN_UI_PASSWORD=CHANGEME
TELESRV_ADMIN_UI_TOKEN=
TELESRV_ADMIN_SESSION_KEY=CHANGEME
TELESRV_TURN_SECRET=CHANGEME
TELESRV_OTP_WEBHOOK_SECRET=CHANGEME
# Development delivery is accepted automatically on loopback. Internet-facing
# deployments must explicitly opt in or replace it with the webhook provider.
TELESRV_DEV_AUTH_CODE=12345
TELESRV_PHONE_CODE_DELIVERY_PROVIDER=development
TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH=false
TELESRV_OTP_WEBHOOK_URL=
TELESRV_OTP_WEBHOOK_TIMEOUT=5s
# The published main test image deliberately contains a public test RSA key so
# fresh test clients share one fingerprint. Existing server_state is preserved.
# Build target "server" plus mode "generated" for a private deployment identity.
TELESRV_RSA_IDENTITY_MODE=test
# Must be a client-reachable IP address, never a DNS name.
TELESRV_ADVERTISE_IP=CHANGEME
TELESRV_SERVER_PORT=2398
TELESRV_DEFAULT_COUNTRY_CODE=CN
TELESRV_PUBLIC_BASE_URL=CHANGEME
TELESRV_PUBLIC_APP_SCHEME=telesrv
TELESRV_PUBLIC_WEB_BASE_URL=CHANGEME
TELESRV_PUBLIC_LINK_PORT=2401
# The monolith owns SFU, TURN, and RTMP. Host mode avoids one Docker mapping per
# TURN relay port. Bridge mode publishes a bounded 64-port compatibility range.
TELESRV_SERVER_HOST_NETWORK=true
TELESRV_SFU_ENABLE=true
TELESRV_SFU_UDP_PORT=12399
TELESRV_SFU_ADVERTISE_IP=CHANGEME
TELESRV_TURN_ENABLE=true
TELESRV_TURN_UDP_PORT=12400
TELESRV_TURN_ADVERTISE_IP=CHANGEME
TELESRV_TURN_RELAY_MIN_PORT=12500
TELESRV_TURN_RELAY_MAX_PORT=12999
TELESRV_TURN_BRIDGE_RELAY_MAX_PORT=12563
TELESRV_CALL_TURN_CREDENTIAL_TTL=6h
TELESRV_CALL_FORCE_RELAY=false
TELESRV_LIVESTREAM_ENABLE=true
TELESRV_LIVESTREAM_RTMP_PORT=2400
TELESRV_LIVESTREAM_RTMP_URL=CHANGEME
# Direct host listeners and their health-probe addresses.
TELESRV_PUBLIC_BIND_IP=127.0.0.1
TELESRV_PUBLIC_LISTEN_HOST=127.0.0.1
TELESRV_LOCAL_BIND_IP=127.0.0.1
TELESRV_LOCAL_LISTEN_HOST=127.0.0.1
TELESRV_SERVER_HEALTH_IP=127.0.0.1
TELESRV_SERVER_HEALTH_URL_HOST=127.0.0.1
TELESRV_ADMIN_API_PORT=2599
TELESRV_ADMIN_BIND_IP=127.0.0.1
TELESRV_ADMIN_LISTEN_HOST=127.0.0.1
TELESRV_ADMIN_HEALTH_IP=127.0.0.1
TELESRV_ADMIN_PORT=2600
# Durable media state. Switching an existing deployment between localfs and S3
# requires the explicit blob migration procedure.
TELESRV_BLOB_BACKEND=localfs
TELESRV_EXTERNAL_MEDIA_ENABLE=true
TELESRV_WEBPAGE_PREVIEW_ENABLE=true
TELESRV_S3_ENDPOINT=
TELESRV_S3_REGION=
TELESRV_S3_BUCKET=
TELESRV_S3_ACCESS_KEY_ID=
TELESRV_S3_SECRET_ACCESS_KEY=
TELESRV_S3_USE_SSL=true
TELESRV_S3_PATH_STYLE=false
TELESRV_S3_CREATE_BUCKET=false

View file

@ -0,0 +1 @@
LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeEYvLzBNMCsvNVB6Z2ROYWdUWCtKK2RKZ3I3NVpDVHVpRzhpNHg3WXdtSkYramlPCkdDam03WDdCTENhTWMxK2hPWllETDMrR3ZsZS9BS3lrVzFxb3VhQ0pNVngvSCsybDhMRlhMZWxaMlBMYXdUYjgKQTdCbFRxV3pMM2RiNUJ1Z01OV3ppTDlUdWhSOEluMWJ3S1kwN1FWcFI5aW41empBc0FHTEJrK21HdDBEblZ5TQpmMVhvcDJsTENGTm1tMEY0eWtjQWVhTENDSVBiR1dkZGxpTFk4eEVFaEk0R08ybDFVM2taTXdJT2RPbkFHSkZ0CmdVQW9UZStGSFI2RjFzOWFkQ1ZaQjF0ZUwvaGY5UitXbWVrSnd5Z1Z6ME1ZRUg3eTZVNDlUNDUrL1c3T0Y2WDYKZzBXMGoxdVNTcnNZNHFON3R3eGJUYWQ5emRHWjd5cys5ditQdVFJREFRQUJBb0lCQUFqdXFPOHhkczBmU0xNKwpEdDdUdXVUTHcyODhDcElBa0EwS3FSYVZuNXh2NWVqMHk1blR1blZSRDY1WGJvb003b04xREY0THVmQk1nM2FmClk3WjRFRGFwVTdRNEZkdzQ3aFJkcks1ODc4WkxmYUhPUTNaVGZyZ3VGMUZ3WjNDZnhSQ1RsOS8vZStwNTVnK1gKamlYY0tZb2lkZUI3dlY5cUdIR3BFRTdRTHFrSUVpbk1FV05hQjh1dGN0SDdUWGRXYTRweWZJR2lQckhNTjJ6SApoRjQwSWI3bkpBNmtodHpzTkNEU0Q5WG5ibEVORW9kMUU1Z1JzalE5ZkdzaGRCdHBEc1hyTEJGTDdLTUREb1FtCmN6cnQvS3hsWk1wYnRPZno2dWE1ZUtFQkJUdE51dG1WY3AxcTl5K2NRcFBaem5ZaVJUTCtPSnpWZ2Z1SE9PLzAKZWEyai93RUNnWUVBNkk2ZitFTG84QVA3UTRXMHFzc3VYS3ZIRUYvUkVFZngyTHVlaDBsclVoVlIzSWdqYWtGYwpiVFJsTEVRSzhRTmJEQW1OTXVIckVabmptR3U3bnkzek45NHNsQy95Y0k1cGQ5aFFHVndPMjJlcjlvMitNOXcyCkpwQjRRZjhjOFNLL3BIUU45K2pPdEJ4VjJkcmJmZTBvVW9LRXRZeUQ1Y2J0akZUM3lmZUEyQ0VDZ1lFQTJDdW8KUzg0MWtWcHB4MUt3cEw0aTFmZ0dRUHlrYUtyRDRvR1pyWWd2MkZ4VzU1aS9xNUZjWUw2ZkUzN0phTTFwby9SQgp0bkhnMzVOYW5nL3l1Wlh6NkViNEswQ3lMKzhMdWhDSWI2UHhEdnF5Q1hia1hUd0hTVlJBbkdFTVNOM3phLzdGCkZDSkYxQUJXZzhqbEtSZEEvbFJ3cW84UDhaZ0JlQk1xcEtaaDVKa0NnWUJoQ0ltazI3NDN6MkY2dGdKQk5VL2QKNk9yQllVbHBJcXU5ZytOTWpZelREZ1EvSVNxdHZpSGppdllmOXpBZGlnbm1SdUg4ZGhsUUdjYkdKVVYrMEh4bwpOaktoampQNVZPS2ExODNzRnVZNEU5VERwamJUaXJHcGU2UkIzVUZsTjl1QXNjL1dQZlJwWUYxTjdpeWhLV0FtCnRVRE1RNW9ST09TTEpqVFJ0NHl5SVFLQmdRQ1RmeTVsRXYyd0FQMzk5K2o1YjVhN1luRjU5Q2lHRmtaMERiUDcKR05wMGlZVHVuMlhndmQxSFVhbWZGcnA4blBRQTM4L2FtZGN6RmdzVm9KSWdtVFdFZnJBa2F3OXA3M1NUNzJYNApydWJ6THBFK0xmWmh1MnpKVndpQzZ5RURzeFc5MFdkTmRwa29yMVpZczBIUmlNRmJCK2ljSitOY0dEaWdZb3VOCkxzM0t1UUtCZ1FDVEF4Vk03ek5QZ3NmZnVkWlA3Rk1ueTIwTE1SNitGUW83eFJ6NUVsWWFDVmdYTTFOSDRLL3IKeUZrT0NHaE9ONkkvTVVOQlB4V0xFdVpCUmZ5cmx5M2JwM2o3UjYvaExYNGZOeXc5QjNHUXVWeUNIRDJENHRvZAo2SjVWby9Fc2VxcGVlS2E2Q3YvWTJIVkIwa2ZHdzFWQ0MvZ00wMUw4dWVkM2hzcjJMRDJrQ3c9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=

View file

@ -0,0 +1,8 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm
7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl
TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo
p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo
Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0
j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB
-----END RSA PUBLIC KEY-----

View file

@ -0,0 +1,63 @@
services:
server:
# Compatibility fallback for hosts without network_mode: host. The bounded
# TURN relay range is published 1:1 to avoid address translation mismatch.
network_mode: !reset null
environment:
TELESRV_LISTEN: 0.0.0.0:${TELESRV_SERVER_PORT:-2398}
TELESRV_PUBLIC_LINK_WEB_ADDR: 0.0.0.0:${TELESRV_PUBLIC_LINK_PORT:-2401}
TELESRV_POSTGRES_DSN: postgres://${POSTGRES_USER:-telesrv}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-telesrv_main}?sslmode=disable
TELESRV_REDIS_ADDR: redis:6379
TELESRV_ADMIN_API_ADDR: 0.0.0.0:${TELESRV_ADMIN_API_PORT:-2599}
TELESRV_TURN_RELAY_MAX_PORT: ${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}
TELESRV_SERVER_HEALTH_IP: 127.0.0.1
TELESRV_SERVER_HEALTH_URL_HOST: 127.0.0.1
ports:
- name: mtproto
target: ${TELESRV_SERVER_PORT:-2398}
published: "${TELESRV_SERVER_PORT:-2398}"
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
protocol: tcp
- name: public-links
target: ${TELESRV_PUBLIC_LINK_PORT:-2401}
published: "${TELESRV_PUBLIC_LINK_PORT:-2401}"
host_ip: ${TELESRV_LOCAL_BIND_IP:-127.0.0.1}
protocol: tcp
- name: rtmp-ingest
target: ${TELESRV_LIVESTREAM_RTMP_PORT:-2400}
published: "${TELESRV_LIVESTREAM_RTMP_PORT:-2400}"
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
protocol: tcp
- name: sfu-media
target: ${TELESRV_SFU_UDP_PORT:-12399}
published: "${TELESRV_SFU_UDP_PORT:-12399}"
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
protocol: udp
- name: turn
target: ${TELESRV_TURN_UDP_PORT:-12400}
published: "${TELESRV_TURN_UDP_PORT:-12400}"
host_ip: ${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}
protocol: udp
- "${TELESRV_PUBLIC_BIND_IP:-127.0.0.1}:${TELESRV_TURN_RELAY_MIN_PORT:-12500}-${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}:${TELESRV_TURN_RELAY_MIN_PORT:-12500}-${TELESRV_TURN_BRIDGE_RELAY_MAX_PORT:-12563}/udp"
networks:
- data
- control
- outbound
admin:
network_mode: !reset null
environment:
TELESRV_POSTGRES_DSN: postgres://${POSTGRES_USER:-telesrv}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-telesrv_main}?sslmode=disable
TELESRV_ADMIN_API_ADDR: server:${TELESRV_ADMIN_API_PORT:-2599}
TELESRV_ADMIN_UI_ADDR: 0.0.0.0:${TELESRV_ADMIN_PORT:-2600}
TELESRV_ADMIN_HEALTH_IP: 127.0.0.1
ports:
- name: admin-ui
target: ${TELESRV_ADMIN_PORT:-2600}
published: "${TELESRV_ADMIN_PORT:-2600}"
host_ip: ${TELESRV_ADMIN_BIND_IP:-127.0.0.1}
protocol: tcp
networks:
- data
- control
- admin_host_access

228
deploy/docker/compose.yaml Normal file
View file

@ -0,0 +1,228 @@
name: ${COMPOSE_PROJECT_NAME:-gramsrv-main}
x-build-args: &build-args
VCS_REF: ${TELESRV_BUILD_COMMIT:-unknown}
VCS_BRANCH: ${TELESRV_BUILD_BRANCH:-main}
VCS_TREE_STATE: ${TELESRV_BUILD_TREE_STATE:-unknown}
BUILD_DATE: ${TELESRV_BUILD_DATE:-unknown}
x-app: &app
init: true
restart: unless-stopped
read_only: true
user: "10001:10001"
cap_drop:
- ALL
pids_limit: 1024
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=128m
stop_grace_period: 1m
logging: &app-logging
driver: json-file
options:
max-size: 10m
max-file: "5"
services:
postgres:
image: postgres:17-alpine@sha256:979c4379dd698aba0b890599a6104e082035f98ef31d9b9291ec22f2b13059ca
environment:
POSTGRES_DB: ${POSTGRES_DB:-telesrv_main}
POSTGRES_USER: ${POSTGRES_USER:-telesrv}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/docker/.env}
POSTGRES_INITDB_ARGS: --data-checksums
TZ: UTC
command:
- postgres
- -c
- max_locks_per_transaction=512
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- pg_stat_statements.track=all
- -c
- track_io_timing=on
shm_size: 256mb
volumes:
- postgres_data:/var/lib/postgresql/data
- ../postgres-init:/docker-entrypoint-initdb.d:ro
ports:
- name: server-host-postgres
target: 5432
published: "${TELESRV_POSTGRES_HOST_PORT:-15432}"
host_ip: 127.0.0.1
protocol: tcp
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
stop_grace_period: 1m
logging: *app-logging
networks:
- data
- server_host_access
redis:
image: redis:7-alpine@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
environment:
REDIS_PASSWORD: ${TELESRV_REDIS_PASSWORD:?set TELESRV_REDIS_PASSWORD in deploy/docker/.env}
command:
- sh
- -ec
- exec redis-server --appendonly yes --appendfsync everysec --requirepass "$$REDIS_PASSWORD"
volumes:
- redis_data:/data
ports:
- name: server-host-redis
target: 6379
published: "${TELESRV_REDIS_HOST_PORT:-16379}"
host_ip: 127.0.0.1
protocol: tcp
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping | grep -qx PONG"]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
restart: unless-stopped
stop_grace_period: 30s
logging: *app-logging
networks:
- data
- server_host_access
server:
<<: *app
image: ${TELESRV_IMAGE_PREFIX:-ghcr.io/iamxvbaba/gramsrv}/server:${TELESRV_IMAGE_TAG:-main}
build:
context: ../..
dockerfile: Dockerfile
target: ${TELESRV_SERVER_BUILD_TARGET:-server-test}
args: *build-args
environment:
TELESRV_LOG_LEVEL: ${TELESRV_LOG_LEVEL:-info}
TELESRV_LISTEN: ${TELESRV_PUBLIC_LISTEN_HOST:-127.0.0.1}:${TELESRV_SERVER_PORT:-2398}
TELESRV_ADVERTISE_IP: ${TELESRV_ADVERTISE_IP:?set TELESRV_ADVERTISE_IP in deploy/docker/.env}
TELESRV_DEFAULT_COUNTRY_CODE: ${TELESRV_DEFAULT_COUNTRY_CODE:-CN}
TELESRV_PUBLIC_BASE_URL: ${TELESRV_PUBLIC_BASE_URL:?set TELESRV_PUBLIC_BASE_URL in deploy/docker/.env}
TELESRV_PUBLIC_APP_SCHEME: ${TELESRV_PUBLIC_APP_SCHEME:-telesrv}
TELESRV_PUBLIC_WEB_BASE_URL: ${TELESRV_PUBLIC_WEB_BASE_URL:?set TELESRV_PUBLIC_WEB_BASE_URL in deploy/docker/.env}
TELESRV_PUBLIC_LINK_WEB_ADDR: ${TELESRV_LOCAL_LISTEN_HOST:-127.0.0.1}:${TELESRV_PUBLIC_LINK_PORT:-2401}
TELESRV_POSTGRES_DSN: ${TELESRV_POSTGRES_DSN:?set TELESRV_POSTGRES_DSN in deploy/docker/.env}
TELESRV_REDIS_ADDR: ${TELESRV_REDIS_ADDR:-127.0.0.1:16379}
TELESRV_REDIS_PASSWORD: ${TELESRV_REDIS_PASSWORD:?set TELESRV_REDIS_PASSWORD in deploy/docker/.env}
TELESRV_REDIS_DB: 0
TELESRV_ADMIN_API_ADDR: 127.0.0.1:${TELESRV_ADMIN_API_PORT:-2599}
TELESRV_ADMIN_API_TOKEN: ${TELESRV_ADMIN_API_TOKEN:?set TELESRV_ADMIN_API_TOKEN in deploy/docker/.env}
TELESRV_DEV_AUTH_CODE: ${TELESRV_DEV_AUTH_CODE:-12345}
TELESRV_PHONE_CODE_DELIVERY_PROVIDER: ${TELESRV_PHONE_CODE_DELIVERY_PROVIDER:-development}
TELESRV_OTP_WEBHOOK_URL: ${TELESRV_OTP_WEBHOOK_URL:-}
TELESRV_OTP_WEBHOOK_SECRET: ${TELESRV_OTP_WEBHOOK_SECRET:-}
TELESRV_OTP_WEBHOOK_TIMEOUT: ${TELESRV_OTP_WEBHOOK_TIMEOUT:-5s}
TELESRV_RSA_KEY: /var/lib/telesrv/server_rsa.pem
TELESRV_RSA_IDENTITY_MODE: ${TELESRV_RSA_IDENTITY_MODE:-test}
TELESRV_SFU_ENABLE: ${TELESRV_SFU_ENABLE:-true}
TELESRV_SFU_UDP_PORT: ${TELESRV_SFU_UDP_PORT:-12399}
TELESRV_SFU_ADVERTISE_IP: ${TELESRV_SFU_ADVERTISE_IP:?set TELESRV_SFU_ADVERTISE_IP in deploy/docker/.env}
TELESRV_TURN_ENABLE: ${TELESRV_TURN_ENABLE:-true}
TELESRV_TURN_UDP_PORT: ${TELESRV_TURN_UDP_PORT:-12400}
TELESRV_TURN_ADVERTISE_IP: ${TELESRV_TURN_ADVERTISE_IP:?set TELESRV_TURN_ADVERTISE_IP in deploy/docker/.env}
TELESRV_TURN_SECRET: ${TELESRV_TURN_SECRET:?set TELESRV_TURN_SECRET in deploy/docker/.env}
TELESRV_TURN_RELAY_MIN_PORT: ${TELESRV_TURN_RELAY_MIN_PORT:-12500}
TELESRV_TURN_RELAY_MAX_PORT: ${TELESRV_TURN_RELAY_MAX_PORT:-12999}
TELESRV_CALL_TURN_CREDENTIAL_TTL: ${TELESRV_CALL_TURN_CREDENTIAL_TTL:-6h}
TELESRV_CALL_FORCE_RELAY: ${TELESRV_CALL_FORCE_RELAY:-false}
TELESRV_LIVESTREAM_ENABLE: ${TELESRV_LIVESTREAM_ENABLE:-true}
TELESRV_LIVESTREAM_RTMP_ADDR: :${TELESRV_LIVESTREAM_RTMP_PORT:-2400}
TELESRV_LIVESTREAM_RTMP_URL: ${TELESRV_LIVESTREAM_RTMP_URL:?set TELESRV_LIVESTREAM_RTMP_URL in deploy/docker/.env}
TELESRV_LIVESTREAM_WORK_DIR: /var/lib/telesrv/livestream
TELESRV_LANGPACK_SEED_DIR: /usr/share/telesrv/langpack
TELESRV_BLOB_BACKEND: ${TELESRV_BLOB_BACKEND:-localfs}
TELESRV_BLOB_DIR: /var/lib/telesrv/blobs
TELESRV_BLOB_STAGING_DIR: /var/lib/telesrv/blob-staging
TELESRV_MAPTILE_CACHE_DIR: /var/lib/telesrv/maptiles
TELESRV_EXTERNAL_MEDIA_ENABLE: ${TELESRV_EXTERNAL_MEDIA_ENABLE:-true}
TELESRV_WEBPAGE_PREVIEW_ENABLE: ${TELESRV_WEBPAGE_PREVIEW_ENABLE:-true}
TELESRV_S3_ENDPOINT: ${TELESRV_S3_ENDPOINT:-}
TELESRV_S3_REGION: ${TELESRV_S3_REGION:-}
TELESRV_S3_BUCKET: ${TELESRV_S3_BUCKET:-}
TELESRV_S3_ACCESS_KEY_ID: ${TELESRV_S3_ACCESS_KEY_ID:-}
TELESRV_S3_SECRET_ACCESS_KEY: ${TELESRV_S3_SECRET_ACCESS_KEY:-}
TELESRV_S3_USE_SSL: ${TELESRV_S3_USE_SSL:-true}
TELESRV_S3_PATH_STYLE: ${TELESRV_S3_PATH_STYLE:-false}
TELESRV_S3_CREATE_BUCKET: ${TELESRV_S3_CREATE_BUCKET:-false}
TELESRV_SERVER_PORT: ${TELESRV_SERVER_PORT:-2398}
TELESRV_PUBLIC_LINK_PORT: ${TELESRV_PUBLIC_LINK_PORT:-2401}
TELESRV_SERVER_HEALTH_IP: ${TELESRV_SERVER_HEALTH_IP:-127.0.0.1}
TELESRV_SERVER_HEALTH_URL_HOST: ${TELESRV_SERVER_HEALTH_URL_HOST:-127.0.0.1}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- server_state:/var/lib/telesrv
network_mode: host
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O /dev/null "http://$${TELESRV_SERVER_HEALTH_URL_HOST}:$${TELESRV_PUBLIC_LINK_PORT}/healthz"
&& nc -z "$${TELESRV_SERVER_HEALTH_IP}" "$${TELESRV_SERVER_PORT}"
interval: 5s
timeout: 5s
retries: 114
start_period: 30s
admin:
<<: *app
image: ${TELESRV_IMAGE_PREFIX:-ghcr.io/iamxvbaba/gramsrv}/admin:${TELESRV_IMAGE_TAG:-main}
build:
context: ../..
dockerfile: Dockerfile
target: admin
args: *build-args
environment:
TELESRV_POSTGRES_DSN: ${TELESRV_POSTGRES_DSN:?set TELESRV_POSTGRES_DSN in deploy/docker/.env}
TELESRV_ADMIN_API_ADDR: 127.0.0.1:${TELESRV_ADMIN_API_PORT:-2599}
TELESRV_ADMIN_API_TOKEN: ${TELESRV_ADMIN_API_TOKEN:?set TELESRV_ADMIN_API_TOKEN in deploy/docker/.env}
TELESRV_ADMIN_UI_ADDR: ${TELESRV_ADMIN_LISTEN_HOST:-127.0.0.1}:${TELESRV_ADMIN_PORT:-2600}
TELESRV_ADMIN_UI_PASSWORD: ${TELESRV_ADMIN_UI_PASSWORD:?set TELESRV_ADMIN_UI_PASSWORD in deploy/docker/.env}
TELESRV_ADMIN_UI_TOKEN: ${TELESRV_ADMIN_UI_TOKEN:-}
TELESRV_ADMIN_SESSION_KEY: ${TELESRV_ADMIN_SESSION_KEY:?set TELESRV_ADMIN_SESSION_KEY in deploy/docker/.env}
TELESRV_BLOB_BACKEND: ${TELESRV_BLOB_BACKEND:-localfs}
TELESRV_BLOB_DIR: /var/lib/telesrv/blobs
TELESRV_BLOB_STAGING_DIR: /var/lib/telesrv/blob-staging
TELESRV_ADMIN_HEALTH_IP: ${TELESRV_ADMIN_HEALTH_IP:-127.0.0.1}
TELESRV_ADMIN_PORT: ${TELESRV_ADMIN_PORT:-2600}
depends_on:
server:
condition: service_healthy
volumes:
- server_state:/var/lib/telesrv:ro
network_mode: host
healthcheck:
test: ["CMD-SHELL", "nc -z \"$$TELESRV_ADMIN_HEALTH_IP\" \"$$TELESRV_ADMIN_PORT\""]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
data:
internal: true
server_host_access:
admin_host_access:
control:
internal: true
outbound:
volumes:
postgres_data:
redis_data:
server_state:

View file

@ -0,0 +1,99 @@
#!/bin/sh
set -eu
umask 077
command_name="${1##*/}"
require_secret() {
name="$1"
value="$(printenv "$name" 2>/dev/null || true)"
normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
case "$normalized" in
""|*changeme*|*change-me*|*replace-me*)
echo "telesrv: required secret $name is missing or still uses a placeholder" >&2
exit 64
;;
esac
}
require_value() {
name="$1"
value="$(printenv "$name" 2>/dev/null || true)"
normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
case "$normalized" in
""|*changeme*|*change-me*|*replace-me*)
echo "telesrv: required setting $name is missing or still uses a placeholder" >&2
exit 64
;;
esac
}
initialize_server_key() {
private_key="${TELESRV_RSA_KEY:-/var/lib/telesrv/server_rsa.pem}"
identity_mode="$(printf '%s' "${TELESRV_RSA_IDENTITY_MODE:-generated}" | tr '[:upper:]' '[:lower:]')"
embedded_private_key=/usr/share/telesrv/keys/test-server-rsa.pem.b64
key_dir=$(dirname -- "$private_key")
mkdir -p "$key_dir"
case "$identity_mode" in
generated) ;;
test)
if [ ! -f "$private_key" ]; then
if [ ! -r "$embedded_private_key" ]; then
echo "telesrv: test RSA identity requested, but this image does not contain the published test key; use the server-test target or set TELESRV_RSA_IDENTITY_MODE=generated" >&2
exit 66
fi
temporary_key="$private_key.tmp.$$"
trap 'rm -f "$temporary_key"' EXIT HUP INT TERM
base64 -d "$embedded_private_key" >"$temporary_key"
chmod 0600 "$temporary_key"
mv "$temporary_key" "$private_key"
trap - EXIT HUP INT TERM
echo "telesrv: WARNING using the published main test RSA identity; its private key is public" >&2
fi
;;
*)
echo "telesrv: TELESRV_RSA_IDENTITY_MODE must be test or generated" >&2
exit 64
;;
esac
if [ -f "$private_key" ]; then
if ! openssl rsa -in "$private_key" -check -noout >/dev/null 2>&1; then
echo "telesrv: $private_key is not a valid RSA private key" >&2
exit 65
fi
chmod 0600 "$private_key"
fi
}
case "$command_name" in
telesrv)
require_value TELESRV_ADVERTISE_IP
require_value TELESRV_PUBLIC_BASE_URL
require_value TELESRV_PUBLIC_WEB_BASE_URL
require_secret TELESRV_POSTGRES_DSN
require_secret TELESRV_REDIS_PASSWORD
require_secret TELESRV_ADMIN_API_TOKEN
require_secret TELESRV_TURN_SECRET
initialize_server_key
;;
telesrv-admin)
require_secret TELESRV_POSTGRES_DSN
require_secret TELESRV_ADMIN_API_TOKEN
require_secret TELESRV_ADMIN_SESSION_KEY
admin_password="$(printenv TELESRV_ADMIN_UI_PASSWORD 2>/dev/null || true)"
admin_token="$(printenv TELESRV_ADMIN_UI_TOKEN 2>/dev/null || true)"
if [ -n "$admin_password" ]; then
require_secret TELESRV_ADMIN_UI_PASSWORD
elif [ -n "$admin_token" ]; then
require_secret TELESRV_ADMIN_UI_TOKEN
else
echo "telesrv: TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is required" >&2
exit 64
fi
;;
esac
exec "$@"

View file

@ -2648,7 +2648,9 @@ CREATE TABLE public.secret_chats (
history_deleted boolean DEFAULT false NOT NULL, history_deleted boolean DEFAULT false NOT NULL,
random_id integer NOT NULL, random_id integer NOT NULL,
date integer NOT NULL, date integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT secret_chats_nonzero_id CHECK ((chat_id <> 0)),
CONSTRAINT secret_chats_random_id_is_chat_id CHECK ((chat_id = random_id))
); );
@ -4524,13 +4526,6 @@ CREATE INDEX upload_parts_object_key_idx ON public.upload_parts USING btree (obj
CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id); CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id);
--
-- Name: uq_secret_chats_admin_random; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX uq_secret_chats_admin_random ON public.secret_chats USING btree (admin_auth_key_id, random_id) WHERE (state <> 'discarded'::text);
-- --
-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: - -- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: -
-- --

View file

@ -0,0 +1,4 @@
-- No-op: see 0181_sticker_set_system_key_unique.up.sql. The real schema
-- change (and its reversal) lives in
-- 20260901000006_sticker_set_system_key_unique.down.sql.
SELECT 1;

View file

@ -0,0 +1,12 @@
-- No-op placeholder. This migration's real content (sticker_sets.system_key
-- uniqueness) already applies via 20260901000006_sticker_set_system_key_unique
-- (identical SQL, applied earlier under our fork's own timestamp-based
-- migration numbering). The version number 181 itself must still exist as a
-- migration, though: internal/store/postgres/postgres.go's Migrate() calls
-- m.Migrate(phoneIdentityPredecessorVersion) (=181) to step a fresh database
-- to exactly this version before running the Go-side phone-identity
-- canonicalization pass, and golang-migrate requires that version to be a
-- real, reachable migration file. Do not reapply the sticker_sets change here
-- -- 20260901000006 already owns it, and repeating it would fail on an
-- already-unique index for any install that runs both.
SELECT 1;

View file

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS public.channel_messages_public_forward_source_seek_idx;
DROP INDEX IF EXISTS public.channel_members_stats_period_idx;
DROP INDEX IF EXISTS public.channel_message_viewers_stats_date_idx;

View file

@ -0,0 +1,18 @@
-- Bounded stats reads: event-time viewers, membership snapshots, and exact
-- public-forward seek pagination by the durable MessageForward JSON shape.
CREATE INDEX channel_message_viewers_stats_date_idx
ON public.channel_message_viewers (channel_id, viewed_at, viewer_user_id, message_id);
CREATE INDEX channel_members_stats_period_idx
ON public.channel_members (channel_id, joined_at, left_at, user_id);
CREATE INDEX channel_messages_public_forward_source_seek_idx
ON public.channel_messages (
(fwd_from #>> '{From,Type}'),
(fwd_from #>> '{From,ID}'),
(fwd_from #>> '{ChannelPost}'),
message_date DESC,
channel_id ASC,
id DESC
)
WHERE NOT deleted AND fwd_from <> '{}'::jsonb;

View file

@ -0,0 +1,112 @@
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_base_read_model() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_id BIGINT;
projection_changed BOOLEAN;
BEGIN
IF TG_OP = 'DELETE' THEN
changed_id := OLD.id;
projection_changed := true;
ELSE
changed_id := NEW.id;
IF TG_OP = 'INSERT' THEN
projection_changed := true;
ELSE
projection_changed :=
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
OLD.phone IS DISTINCT FROM NEW.phone OR
OLD.first_name IS DISTINCT FROM NEW.first_name OR
OLD.last_name IS DISTINCT FROM NEW.last_name OR
OLD.username IS DISTINCT FROM NEW.username OR
OLD.country_code IS DISTINCT FROM NEW.country_code OR
OLD.verified IS DISTINCT FROM NEW.verified OR
OLD.support IS DISTINCT FROM NEW.support OR
OLD.about IS DISTINCT FROM NEW.about OR
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
OLD.color_set IS DISTINCT FROM NEW.color_set OR
OLD.color IS DISTINCT FROM NEW.color OR
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
END IF;
END IF;
IF projection_changed THEN
PERFORM telesrv_bump_read_model_version('user_base', changed_id, 'user', changed_id);
IF TG_OP = 'INSERT' THEN
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
END IF;
PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id)
FROM contacts c
WHERE c.contact_user_id = changed_id;
PERFORM telesrv_bump_private_dialog_light_for_user(changed_id);
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_channel_participants_read_model() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_id BIGINT;
old_id BIGINT;
projection_changed BOOLEAN;
BEGIN
IF TG_OP = 'DELETE' THEN
changed_id := OLD.id;
projection_changed := true;
ELSE
changed_id := NEW.id;
IF TG_OP = 'INSERT' THEN
projection_changed := true;
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
ELSE
old_id := OLD.id;
projection_changed :=
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
OLD.phone IS DISTINCT FROM NEW.phone OR
OLD.first_name IS DISTINCT FROM NEW.first_name OR
OLD.last_name IS DISTINCT FROM NEW.last_name OR
OLD.username IS DISTINCT FROM NEW.username OR
OLD.country_code IS DISTINCT FROM NEW.country_code OR
OLD.verified IS DISTINCT FROM NEW.verified OR
OLD.support IS DISTINCT FROM NEW.support OR
OLD.about IS DISTINCT FROM NEW.about OR
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
OLD.color_set IS DISTINCT FROM NEW.color_set OR
OLD.color IS DISTINCT FROM NEW.color OR
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
IF old_id IS DISTINCT FROM changed_id THEN
PERFORM telesrv_bump_channel_participants_for_user(old_id);
END IF;
END IF;
END IF;
IF projection_changed THEN
PERFORM telesrv_bump_channel_participants_for_user(changed_id);
END IF;
RETURN NULL;
END;
$$;
DROP TRIGGER IF EXISTS users_release_collectible_phone_on_soft_delete ON public.users;
CREATE TRIGGER users_release_collectible_phone_on_soft_delete
BEFORE UPDATE OF deleted_at ON public.users
FOR EACH ROW WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL)
EXECUTE FUNCTION public.release_soft_deleted_user_collectible_phone();

View file

@ -0,0 +1,129 @@
-- Human account deletion is a logical user tombstone. The deleted user keeps
-- all relationship/history rows, so the users UPDATE must not fan out through
-- every reverse contact, dialog and channel membership. A dedicated event
-- invalidates the base-user and RPC projection caches as one coarse boundary.
-- Collectible phone ownership is an account asset, not the editable users.phone
-- identity field. Logical deletion preserves it; physical user deletion keeps
-- the separate 0171 release trigger.
DROP TRIGGER IF EXISTS users_release_collectible_phone_on_soft_delete ON public.users;
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_base_read_model() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_id BIGINT;
projection_changed BOOLEAN;
BEGIN
IF TG_OP = 'UPDATE'
AND OLD.deleted_at IS NULL
AND NEW.deleted_at IS NOT NULL THEN
PERFORM telesrv_bump_read_model_version('user_deleted', NEW.id, 'user', NEW.id);
RETURN NULL;
END IF;
IF TG_OP = 'DELETE' THEN
changed_id := OLD.id;
projection_changed := true;
ELSE
changed_id := NEW.id;
IF TG_OP = 'INSERT' THEN
projection_changed := true;
ELSE
projection_changed :=
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
OLD.phone IS DISTINCT FROM NEW.phone OR
OLD.first_name IS DISTINCT FROM NEW.first_name OR
OLD.last_name IS DISTINCT FROM NEW.last_name OR
OLD.username IS DISTINCT FROM NEW.username OR
OLD.country_code IS DISTINCT FROM NEW.country_code OR
OLD.verified IS DISTINCT FROM NEW.verified OR
OLD.support IS DISTINCT FROM NEW.support OR
OLD.about IS DISTINCT FROM NEW.about OR
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
OLD.color_set IS DISTINCT FROM NEW.color_set OR
OLD.color IS DISTINCT FROM NEW.color OR
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
END IF;
END IF;
IF projection_changed THEN
PERFORM telesrv_bump_read_model_version('user_base', changed_id, 'user', changed_id);
IF TG_OP = 'INSERT' THEN
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
END IF;
PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id)
FROM contacts c
WHERE c.contact_user_id = changed_id;
PERFORM telesrv_bump_private_dialog_light_for_user(changed_id);
END IF;
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.telesrv_notify_user_channel_participants_read_model() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
changed_id BIGINT;
old_id BIGINT;
projection_changed BOOLEAN;
BEGIN
IF TG_OP = 'UPDATE'
AND OLD.deleted_at IS NULL
AND NEW.deleted_at IS NOT NULL THEN
RETURN NULL;
END IF;
IF TG_OP = 'DELETE' THEN
changed_id := OLD.id;
projection_changed := true;
ELSE
changed_id := NEW.id;
IF TG_OP = 'INSERT' THEN
projection_changed := true;
PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id);
ELSE
old_id := OLD.id;
projection_changed :=
OLD.access_hash IS DISTINCT FROM NEW.access_hash OR
OLD.phone IS DISTINCT FROM NEW.phone OR
OLD.first_name IS DISTINCT FROM NEW.first_name OR
OLD.last_name IS DISTINCT FROM NEW.last_name OR
OLD.username IS DISTINCT FROM NEW.username OR
OLD.country_code IS DISTINCT FROM NEW.country_code OR
OLD.verified IS DISTINCT FROM NEW.verified OR
OLD.support IS DISTINCT FROM NEW.support OR
OLD.about IS DISTINCT FROM NEW.about OR
OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR
OLD.is_bot IS DISTINCT FROM NEW.is_bot OR
OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR
OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR
OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR
OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR
OLD.color_set IS DISTINCT FROM NEW.color_set OR
OLD.color IS DISTINCT FROM NEW.color OR
OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR
OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR
OLD.profile_color IS DISTINCT FROM NEW.profile_color OR
OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id;
IF old_id IS DISTINCT FROM changed_id THEN
PERFORM telesrv_bump_channel_participants_for_user(old_id);
END IF;
END IF;
END IF;
IF projection_changed THEN
PERFORM telesrv_bump_channel_participants_for_user(changed_id);
END IF;
RETURN NULL;
END;
$$;

Some files were not shown because too many files have changed in this diff Show more