Compare commits

..

2 commits

Author SHA1 Message Date
4d3c6beddf Merge branch 'feat/admin-username-min-length-and-docs'
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
2026-09-17 15:24:36 +01:00
c39011e542 admin: allow shorter operator-assigned usernames, add reference docs
Lower the username floor to 3 characters on the admin-only write path
(account/channel set-username), separate from the 5-character self-service
minimum, since a deliberately short operator handle isn't the squatting the
higher floor guards against. Channel admin username assignment previously
had no length/format validation at all; this adds it.

Also adds three reference docs: the client's deep-link (tg://, owpg://)
surface, an OpenAPI 3.1 spec for both admin HTTP APIs (built from source,
not the stale docs/admin-panel-api.en.md), and a starter reserved-username
blocklist.
2026-09-17 15:24:31 +01:00
5 changed files with 3861 additions and 5 deletions

119
docs/deep-links.md Normal file
View file

@ -0,0 +1,119 @@
# Client deep links
The desktop client (`owpengram-desktop-client`, a Telegram Desktop fork) resolves
`tg://<path>` URIs and `https://t.me/<path>` links through the same handler table:
`Telegram/SourceFiles/core/local_url_handlers.cpp`. Both schemes are normalized to the
same `<path>?<query>` form before dispatch, so every entry below works as either
`tg://join?invite=...` or `t.me/join/...` (the client also accepts the shorter
`t.me/+...` / `t.me/joinchat/...` aliases, which get rewritten to `join?invite=`
before reaching this table).
Patterns are matched in order top to bottom; the first regex match wins.
## Path handlers
| Path | Handler | Purpose |
|---|---|---|
| `join?invite=<hash>` | `JoinGroupByHash` | Join a chat/channel via invite link hash. |
| `addlist?slug=<slug>` | `JoinFilterBySlug` | Import a shareable chat folder. |
| `addstickers?set=<name>` / `addemoji?set=<name>` | `ShowStickerSet` | Open a sticker or custom-emoji set for install. |
| `addtheme?slug=<slug>` | `ShowTheme` | Open a shared theme for install. |
| `addstyle?slug=<slug>` | `ShowAiStyle` | Open a shared AI compose style. |
| `setlanguage?lang=<code>` | `SetLanguage` | Switch/install the app's interface language. |
| `msg_url?<params>` | `ShareUrl` | Open the "share to chat" composer for an external URL. |
| `confirmphone?<params>` | `ConfirmPhone` | Confirm a phone number transfer/login code sent by SMS. |
| `socks?<params>` | `ApplySocksProxy` | Add/apply a SOCKS5 proxy config. |
| `proxy?<params>` | `ApplyMtprotoProxy` | Add/apply an MTProto proxy config. |
| `webproxy?<params>` | `ApplyWebProxy` | Add/apply a web proxy config. |
| `passport?<params>` | `ShowPassport` | Telegram Passport authorization request. |
| `bg?<params>` | `ShowWallPaper` | Open a shared chat wallpaper for install. |
| `resolve?<params>` | `ResolveUsernameOrPhone` | Resolve `@username`/phone to a peer; see [resolve parameters](#resolve-parameters) below. This is the most common deep link kind. |
| `privatepost?<params>` | `ResolvePrivatePost` | Open a specific message inside a private channel by internal channel id + message id. |
| `test_chat_theme?<params>` | `ResolveTestChatTheme` | Preview a chat theme without installing it. |
| `invoice?<params>` | `ResolveInvoice` | Open a payment invoice (Telegram Payments / Stars). |
| `premium_offer[?<params>]` | `ResolvePremiumOffer` | Open the Premium subscription offer screen. |
| `premium_multigift?<params>` | `ResolvePremiumMultigift` | Open the bulk/multi-recipient Premium gifting screen. |
| `login?code=<n>` | `ResolveLoginCode` | Apply a login confirmation code (QR/code login flow). |
| `boost?<params>` | `ResolveBoost` | Open the "boost this channel" flow. |
| `message?slug=<slug>` | `ResolveChatLink` | Open a pre-composed shareable chat/business link. |
| `stars_topup?<params>` | `ResolveTopUp` | Open the Telegram Stars top-up screen. |
| `nft?slug=<slug>` | `ResolveUniqueGift` | Open a collectible/unique gift by slug. |
| `stargift_auction?slug=<slug>` | `ResolveGiftAuction` | Open a Star Gift auction listing. |
| `call?slug=<slug>` | `ResolveConferenceCall` | Join a group/conference call by invite slug. |
| `stars[?<params>]` | `ResolveStarsSettings` | Open Telegram Stars balance/settings. |
| `ton[?<params>]` / `grams[?<params>]` | `ResolveTonSettings` | Open TON wallet settings. |
| `oauth?<params>` | `ResolveOAuth` | Third-party OAuth authorization via Telegram. |
| *(anything else)* | `HandleUnknown` | Fallback — shown as an unrecognized-link message. |
## Resolve parameters
`resolve?domain=<username>` (or the bare `t.me/<username>` shorthand, which the
client rewrites to this form) accepts additional query parameters that change what
opens once the peer is resolved:
| Parameter | Effect |
|---|---|
| `domain` | The `@username` to resolve (required unless using the bare `t.me/<username>` form). |
| `phone` | Resolve by phone number instead of username. |
| `appname` | Open a Telegram Mini App by name instead of the peer's chat. |
| `startapp` | Start-parameter passed into a Mini App launch. |
| `attach` | Open the target's attach-menu bot alongside the chat. |
| `start` | Start-parameter for a bot (`/start <token>`). |
| `startgroup` | Add a bot to a group with this start token (bot-in-group flow). |
| `startchannel` | Add a bot to a channel with this start token. |
| `post` | Jump to a specific message id in the resolved peer. |
| `story` | Open a specific story id from the resolved peer. |
| `comment` | Jump to a specific comment/reply message id (discussion groups). |
| `topic` | Open a specific forum topic id. |
| `thread` | Open a specific reply thread/topic id (older alias of `topic`). |
| `game` | Launch a specific inline game short name from a bot. |
| `text` | Pre-fill the message composer with this text. |
| `voicechat` / `videochat` / `livestream` | Join the peer's active voice chat / video chat / live stream by hash. |
## Internal-only links
`InternalUrlHandlers()` in the same file (`internal:<path>`) covers links the app
generates for its own in-app UI (e.g. `internal:edit_peer/<id>`,
`internal:common_groups/<id>`, `internal:collectible_username/<name>@<id>`). These
are not meant to be shared externally and are not part of the public deep-link
surface — they only work as links the client itself produces inside its own UI.
## OwpenGram (`owpg://`) links
This fork adds a second scheme, handled in `Telegram/SourceFiles/core/application.cpp`
(`Application::openLocalUrl` / `Application::openOwpengramUrl`), for operating across
multiple OwpenGram-compatible backends rather than only the official Telegram DCs.
| Form | Purpose |
|---|---|
| `owpg://addserver?host=<host>&port=<port>` | Opens `AddServerBox` pre-filled with just the address. **Deliberately carries nothing else** — no name, description, RSA key, or DC id. |
| `owpg://oauth?<params>` / `owpg://resolve?<params>` | Rewritten to the equivalent `tg://oauth` / `tg://resolve` command and dispatched on the current session — lets those two flows work on an OwpenGram account, not just official Telegram. |
| `owpg://<host>/<rest>` | Routes a normal `t.me`-style path (`<rest>` = `+<invite>`, `<username>`, etc.) to whichever logged-in account is on that server's `<host>`, bypassing the official-Telegram-only guard. If no account on the device is logged into `<host>`, the client shows an inform box naming the server instead of silently failing. |
### Why `addserver` only takes host+port
The RSA public key, main DC id, display name, and description are **never** accepted
from the link itself — see the comment above `kOwpgAddServerRe` in `application.cpp`.
Letting a link author supply the key/identity directly would be a MITM vector: whoever
crafts the link would control what the victim ends up trusting as that server's
identity, regardless of who actually operates the host. Only host+port travel in the
link; everything else is fetched live and is exactly as trustworthy as the TCP
connection to that host:port already is.
Once `AddServerBox` has host+port, it calls `Owpengram::FetchServerInfo(host, port, …)`,
which does a plain HTTP `GET` against the server's own well-known endpoints on the
same port:
| Endpoint | Returns |
|---|---|
| `GET <host>:<port>/owpengram/server-info` | RSA public key (PEM), main DC id, and optional operator-set name/description/icon-presence flag. |
| `GET <host>:<port>/owpengram/server-icon` | Raw icon bytes, only fetched when `server-info` reported `hasIcon: true`. |
`RefreshServersInfo()` re-polls `server-info`/`server-icon` for every already-added
custom server to keep a cosmetic name/icon change in sync — it never touches host,
port, RSA key, DC id, or the server's local id, for the same MITM reason as above (see
the doc comment on `RefreshServersInfo` in `owpengram_servers.h`).
This also means the RSA-key-in-a-URL scheme discussed earlier in this doc's history
is intentionally **not** how this client adds a server — that was a plausible-looking
design that this project already considered and rejected in favor of fetch-after-add.

3628
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,73 @@
# Suggested reserved usernames
A starter blocklist for `TELESRV_RESERVED_USERNAMES` (see
`internal/config/config.go`, `ReservedUsernames`), beyond the shipped
default (`owpengram, admin, administrator, support, staff, moderator,
official, root, owner`). The mechanism blocks these, case-insensitively,
from self-service `account.updateUsername` / `channels.updateUsername`
an operator can still assign one deliberately from the admin panel, since
that's not the squatting this exists to stop.
Organized by why each entry is worth blocking. Trim or extend per your own
deployment — none of this is enforced by the server itself, it's just a
starting list to feed into the env var.
## Official-sounding / staff-impersonation
Names that would read as "this account speaks for the operator" if a
random user grabbed them.
```text
telegram, telegramofficial, telegramsupport, telegramteam, telegramstaff,
telegramhq, telegramnews, telegramnotifications, telegramverify, telegrambot,
telegramservice, security, securityteam, verify, verification, verified,
helpdesk, helpcenter, customerservice, customersupport, servicedesk, service,
system, systemmessage, systembot, notifications, notification, alerts,
announcements, announcement, newsroom, press, pressoffice, spambot, antispam,
abuse, abusereport, reports, legal, compliance, billing, payments, finance,
accounts, accountservices, recovery, unlock, restore, backup, headquarters,
staffteam, modteam, moderation, moderators, founder, cofounder, management
```
## Founders / executives (real people this server is not affiliated with)
Blocks an account from presenting as Telegram's actual leadership.
```text
durov, pavel, paveldurov, pdurov, nikolai, nikolaidurov, ndurov, durovpavel,
telegramceo, telegramfounder
```
## Crypto / scam-bait
The classic "free crypto giveaway" impersonation targets — official-sounding
project/exchange names, plus generic scam-pattern handles.
```text
bitcoin, ethereum, toncoin, tonwallet, tonfoundation, crypto, cryptowallet,
wallet, airdrop, giveaway, bonus, freecoins, claim, claimnow, binance,
coinbase, kraken, metamask, trustwallet, ledger, opensea, uniswap, elonmusk,
tesla, spacex, cryptosupport, walletsupport, tonsupport, binancesupport,
investment, investmentteam, forextrading, tradingbot, signals, cryptosignals
```
## Suggested env var
Comma-separated, matching `envListOr`'s parsing:
```bash
TELESRV_RESERVED_USERNAMES=owpengram,admin,administrator,support,staff,moderator,official,root,owner,telegram,telegramofficial,telegramsupport,telegramteam,telegramstaff,telegramhq,telegramnews,telegramnotifications,telegramverify,telegrambot,telegramservice,security,securityteam,verify,verification,verified,helpdesk,helpcenter,customerservice,customersupport,servicedesk,service,system,systemmessage,systembot,notifications,notification,alerts,announcements,announcement,newsroom,press,pressoffice,spambot,antispam,abuse,abusereport,reports,legal,compliance,billing,payments,finance,accounts,accountservices,recovery,unlock,restore,backup,headquarters,staffteam,modteam,moderation,moderators,founder,cofounder,management,durov,paveldurov,pdurov,nikolai,nikolaidurov,ndurov,durovpavel,telegramceo,telegramfounder,bitcoin,ethereum,toncoin,tonwallet,tonfoundation,crypto,cryptowallet,wallet,airdrop,giveaway,bonus,freecoins,claim,claimnow,binance,coinbase,kraken,metamask,trustwallet,ledger,opensea,uniswap,elonmusk,tesla,spacex,cryptosupport,walletsupport,tonsupport,binancesupport,investment,investmentteam,forextrading,tradingbot,signals,cryptosignals
```
## What this doesn't cover
- **Collectible usernames** (`internal/admin/service.go` collectible-username
actions) go through the admin panel/Admin API directly and aren't gated
by this list at all — an operator minting one bypasses it on purpose.
- **Prefix/substring squatting** (`telegram_real`, `xtelegram`, `te1egram`)
— the blocklist is an exact-match (case-insensitive) list, not a pattern
matcher, so lookalike spellings need their own entries if you want them
blocked too.
- This is a *username* blocklist only — it says nothing about display
names, bios, or profile photos, which can impersonate just as easily and
aren't restricted by `TELESRV_RESERVED_USERNAMES` at all.

View file

@ -635,11 +635,19 @@ func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch d
return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
}
// AdminSetUsername force-sets or clears a channel username through the admin path.
// AdminSetUsername force-sets or clears a channel username through the admin
// path. Unlike UpdateUsername it does not consult config.ReservedUsernames --
// an operator who deliberately reserved a word still needs to be able to hand
// it to a specific channel -- but format/length validity is still enforced,
// just against the lower minChannelUsernameLenAdmin floor.
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
username = normalizeChannelUsername(username)
if username != "" && !validChannelUsernameMinLen(username, minChannelUsernameLenAdmin) {
return domain.Channel{}, domain.ErrUsernameInvalid
}
return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
}
@ -2520,8 +2528,20 @@ func normalizeChannelUsername(username string) string {
return strings.TrimSpace(username)
}
// minChannelUsernameLenAdmin is the floor for an operator-assigned channel
// username via AdminSetUsername (the admin API/panel) -- shorter than what
// self-service channels.updateUsername allows, mirroring
// users.minUsernameLenAdmin: a deliberate short handle assigned by an
// operator is not the squatting the higher self-service minimum guards
// against.
const minChannelUsernameLenAdmin = 3
func validChannelUsername(username string) bool {
if len(username) < 5 || len(username) > 32 {
return validChannelUsernameMinLen(username, 5)
}
func validChannelUsernameMinLen(username string, minLen int) bool {
if len(username) < minLen || len(username) > 32 {
return false
}
for i := 0; i < len(username); i++ {

View file

@ -117,6 +117,12 @@ func WithReservedUsernames(names []string) Option {
const (
minUsernameLen = 5
// minUsernameLenAdmin is the floor for an operator-assigned username via
// UpdateUsernameAdmin (the admin API/panel) -- shorter than what self-service
// account.updateUsername allows, since a deliberate short handle assigned by
// an operator (e.g. a 3-4 char official/brand account) is not the squatting
// the higher self-service minimum guards against.
minUsernameLenAdmin = 3
maxUsernameLen = 32
maxProfileNameRunes = 64
// bio 长度双档,对齐 appConfig about_length_limit_default=70 /
@ -383,7 +389,13 @@ func (s *Service) updateUsername(ctx context.Context, userID int64, username str
return s.projectOne(ctx, self.ID, self)
}
if username != "" {
if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) {
minLen := minUsernameLen
if !enforceReserved {
// enforceReserved=false is exactly the admin-bypass path
// (UpdateUsernameAdmin) -- see minUsernameLenAdmin's doc comment.
minLen = minUsernameLenAdmin
}
if !validUsernameMinLen(username, minLen) || (enforceReserved && s.reserved.Contains(username)) {
return domain.User{}, domain.ErrUsernameInvalid
}
var (
@ -1040,7 +1052,11 @@ func normalizeUsername(username string) string {
}
func validUsername(username string) bool {
if len(username) < minUsernameLen || len(username) > maxUsernameLen {
return validUsernameMinLen(username, minUsernameLen)
}
func validUsernameMinLen(username string, minLen int) bool {
if len(username) < minLen || len(username) > maxUsernameLen {
return false
}
for i := 0; i < len(username); i++ {