docs: sync public link configuration reference

This commit is contained in:
A 2026-07-11 20:34:10 +08:00
parent 4390ebf5a9
commit c0088f1160
13 changed files with 857 additions and 100 deletions

View file

@ -1,5 +1,8 @@
# Optional local config file for telesrv. # Optional local config file for telesrv.
# Copy to .env for local development. Do not commit real tokens or passwords. # Copy to .env for local development. Do not commit real tokens or passwords.
# Complete reference / 完整参数手册:
# docs/configuration.en.md
# docs/configuration.zh-CN.md
TELESRV_LISTEN=0.0.0.0:2398 TELESRV_LISTEN=0.0.0.0:2398
TELESRV_ADVERTISE_IP=127.0.0.1 TELESRV_ADVERTISE_IP=127.0.0.1
@ -54,6 +57,14 @@ TELESRV_SMTP_TIMEOUT=10s
# or a missing host fail startup instead of silently falling back. # or a missing host fail startup instead of silently falling back.
TELESRV_PUBLIC_BASE_URL=https://telesrv.net TELESRV_PUBLIC_BASE_URL=https://telesrv.net
# Public landing pages auto-open this custom scheme. It must match the scheme
# registered by every patched client build; tg/http/https are rejected.
TELESRV_PUBLIC_APP_SCHEME=telesrv
# Web client target and display brand used by public landing pages.
TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net
TELESRV_PUBLIC_APP_NAME=telesrv
# Admin API / Admin UI 配置 # Admin API / Admin UI 配置
# #
# TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token, # TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token,

View file

@ -98,10 +98,14 @@ workers in the same process.
Useful local environment variables: Useful local environment variables:
See the complete [English configuration reference](docs/configuration.en.md) or
the [Chinese configuration reference](docs/configuration.zh-CN.md). `.env.example`
is a copyable development template, not an exhaustive parameter dictionary.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---:|---| |---|---:|---|
| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto listen address | | `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto listen address |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients | | `TELESRV_ADVERTISE_IP` | `127.0.0.1` | client-reachable fallback IP for media and calls |
| `TELESRV_DC` | `2` | self-hosted DC id | | `TELESRV_DC` | `2` | self-hosted DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development | | `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | wrong-code attempts before the code hash is deleted | | `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | wrong-code attempts before the code hash is deleted |
@ -109,6 +113,9 @@ Useful local environment variables:
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | force phone login/registration to set a login email first | | `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | force phone login/registration to set a login email first |
| `TELESRV_SMTP_HOST` | empty | SMTP host used when login email verification is enabled | | `TELESRV_SMTP_HOST` | empty | SMTP host used when login email verification is enabled |
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | canonical external base URL for username, sticker, emoji, and chatlist links | | `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | canonical external base URL for username, sticker, emoji, and chatlist links |
| `TELESRV_PUBLIC_APP_SCHEME` | `telesrv` | custom URL scheme opened by public landing pages |
| `TELESRV_PUBLIC_WEB_BASE_URL` | `https://web.telesrv.net` | Web client base URL shown on public landing pages |
| `TELESRV_PUBLIC_APP_NAME` | `telesrv` | display product name for public landing pages |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string | | `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address | | `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address |
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory |
@ -185,6 +192,9 @@ links:
```env ```env
TELESRV_PUBLIC_BASE_URL=https://your-domain.example TELESRV_PUBLIC_BASE_URL=https://your-domain.example
TELESRV_PUBLIC_APP_SCHEME=yourapp
TELESRV_PUBLIC_WEB_BASE_URL=https://web.your-domain.example
TELESRV_PUBLIC_APP_NAME=YourApp
``` ```
In production, keep `TELESRV_PUBLIC_LINK_WEB_ADDR` on loopback and reverse-proxy In production, keep `TELESRV_PUBLIC_LINK_WEB_ADDR` on loopback and reverse-proxy

View file

@ -90,10 +90,14 @@ go build -o bin/gramsrv ./cmd/telesrv
常用本地环境变量: 常用本地环境变量:
完整说明见[中文配置参数手册](docs/configuration.zh-CN.md)和
[英文配置参数手册](docs/configuration.en.md)。`.env.example` 只作为可直接复制的开发模板,
不再承担完整参数字典的职责。
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
|---|---:|---| |---|---:|---|
| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto 监听地址 | | `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto 监听地址 |
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP | | `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 媒体与通话使用的客户端可达回退 IP |
| `TELESRV_DC` | `2` | 自建 DC id | | `TELESRV_DC` | `2` | 自建 DC id |
| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 | | `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | 同一验证码 hash 允许的错误次数,达到后删除并要求重发 | | `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | `5` | 同一验证码 hash 允许的错误次数,达到后删除并要求重发 |
@ -101,6 +105,9 @@ go build -o bin/gramsrv ./cmd/telesrv
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | 登录/注册时强制先设置登录邮箱 | | `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | `false` | 登录/注册时强制先设置登录邮箱 |
| `TELESRV_SMTP_HOST` | 空 | 开启登录邮箱验证时使用的 SMTP host | | `TELESRV_SMTP_HOST` | 空 | 开启登录邮箱验证时使用的 SMTP host |
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | username、sticker、emoji、chatlist 公开链接使用的外部 canonical base URL | | `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | username、sticker、emoji、chatlist 公开链接使用的外部 canonical base URL |
| `TELESRV_PUBLIC_APP_SCHEME` | `telesrv` | 公开落地页唤起客户端使用的自定义 URL scheme |
| `TELESRV_PUBLIC_WEB_BASE_URL` | `https://web.telesrv.net` | 公开落地页展示的 Web 客户端根地址 |
| `TELESRV_PUBLIC_APP_NAME` | `telesrv` | 公开落地页展示的产品名 |
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 | | `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 |
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 | | `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 |
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 |
@ -173,6 +180,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
```env ```env
TELESRV_PUBLIC_BASE_URL=https://your-domain.example TELESRV_PUBLIC_BASE_URL=https://your-domain.example
TELESRV_PUBLIC_APP_SCHEME=yourapp
TELESRV_PUBLIC_WEB_BASE_URL=https://web.your-domain.example
TELESRV_PUBLIC_APP_NAME=YourApp
``` ```
生产环境建议让 `TELESRV_PUBLIC_LINK_WEB_ADDR` 只监听 loopback,再用 HTTPS 生产环境建议让 `TELESRV_PUBLIC_LINK_WEB_ADDR` 只监听 loopback,再用 HTTPS

View file

@ -64,7 +64,7 @@ import (
"telesrv/internal/store/postgres" "telesrv/internal/store/postgres"
"telesrv/internal/store/redisstore" "telesrv/internal/store/redisstore"
"telesrv/internal/turnsrv" "telesrv/internal/turnsrv"
"telesrv/internal/web/stickerlinks" "telesrv/internal/web"
) )
func main() { func main() {
@ -770,15 +770,19 @@ func run(logger *zap.Logger) error {
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil { if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
return fmt.Errorf("start admin api: %w", err) return fmt.Errorf("start admin api: %w", err)
} }
if _, err := stickerlinks.Start(ctx, stickerlinks.Config{ if _, err := web.Start(ctx, web.Config{
Addr: cfg.PublicLinkWebAddr, Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme,
WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName,
StickerSets: filesService,
Users: userStore, Users: userStore,
Channels: channelStore, Channels: channelStore,
Privacy: privacyService, Privacy: privacyService,
Photos: filesService, Photos: filesService,
}, filesService, logger.Named("stickerlinks")); err != nil { }, logger.Named("public-web")); err != nil {
return fmt.Errorf("start sticker links: %w", err) return fmt.Errorf("start public Web: %w", err)
} }
srv := mtprotoedge.New(mtprotoedge.Options{ srv := mtprotoedge.New(mtprotoedge.Options{

221
docs/configuration.en.md Normal file
View file

@ -0,0 +1,221 @@
# telesrv configuration reference
Chinese version: [configuration.zh-CN.md](configuration.zh-CN.md)
This document describes every setting loaded by `internal/config`. Defaults and validation behavior in `internal/config/config.go` are authoritative. All settings require a process restart; telesrv does not hot-reload configuration.
## 1. Loading, syntax, and precedence
- `TELESRV_CONFIG` is a **process environment variable** selecting the env-style configuration file. Default: `.env` in the process working directory. An explicit empty value disables file loading. Setting it inside the file has no effect because the file has already been selected.
- Precedence is: non-empty process environment value → non-empty file value → code default. The nullable listener settings (`TELESRV_DEBUG_ADDR`, `TELESRV_BOT_API_ADDR`, `TELESRV_ADMIN_API_ADDR`, and `TELESRV_PUBLIC_LINK_WEB_ADDR`) additionally allow an explicitly empty process value to disable a non-empty file value.
- The file accepts blank lines, full-line `#` comments, optional `export `, and `KEY=VALUE`. Single- and double-quoted values are supported. Inline comments are not stripped.
- File keys must start with `TELESRV_` and contain only uppercase ASCII letters, digits, and underscores. Unknown `TELESRV_*` keys are syntactically accepted but ignored by the current binary.
- Booleans accept `1/true/TRUE/True/yes/on` and `0/false/FALSE/False/no/off`. Lists are comma-separated. Durations use Go duration syntax such as `200ms`, `30s`, `5m`, or `168h`.
- Invalid integer, float, boolean, or duration text falls back to the code default. URL, app-scheme, app-name, and login-email dependency validation fails startup instead.
- Never commit real passwords, tokens, private DSNs, or TURN secrets. Prefer a secret manager or protected service environment in production.
## 2. MTProto listener, transport, and resource budgets
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_LISTEN` | string / `0.0.0.0:2398` | MTProto TCP listen address. Must match the address/port reachable by patched clients. |
| `TELESRV_ADVERTISE_IP` | string / `127.0.0.1` | Client-reachable server IP used by media/call fallbacks. The current static Desktop DC patch does not derive its MTProto endpoint from this value. |
| `TELESRV_RSA_KEY` | path / `data/server_rsa.pem` | MTProto RSA private key. Generated when missing. Treat the file as a secret and keep it stable across restarts. |
| `TELESRV_DC` | int / `2` | Server DC ID. Must match patched client expectations and stored media/DC metadata. |
| `TELESRV_WEBSOCKET_ENABLE` | bool / `true` | Enables MTProto-over-WebSocket demultiplexing on the MTProto listener. |
| `TELESRV_WEBSOCKET_ALLOWED_ORIGINS` | list / `http://localhost:1234,http://127.0.0.1:1234` | Browser WebSocket origin allow-list. `*` is for temporary debugging only. |
| `TELESRV_MTPROTO_MAX_CONNECTIONS` | int / `200000` | Global physical connection admission limit. Negative disables this gate. |
| `TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP` | int / `4096` | Per-source-IP physical connection limit. Negative disables this gate. |
| `TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES` | int / `256` | Concurrent expensive RSA/DH handshakes. Negative disables this gate. |
| `TELESRV_MTPROTO_RPC_MAX_INFLIGHT` | int / `32` | Per-connection concurrent RPC budget; non-positive values are normalized by the edge to its safe default. |
| `TELESRV_MTPROTO_RPC_QUEUE_SIZE` | int / `64` | Per-connection queued RPC budget; non-positive values use the edge default. |
| `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | End-to-end handler timeout for scheduled RPC work. |
| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | Shared fair-scheduler worker count. |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | Process-wide scheduled/in-flight RPC task cap. |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide queued/in-flight RPC request-body budget. |
| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide reservation for transport wire bytes plus maximum decrypted plaintext, acquired before payload allocation. |
| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | Per-connection normal outbound mailbox capacity. |
| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | Per-connection control-message mailbox capacity. |
| `TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Global budget for tracked resend-pending message bodies. |
| `TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Global budget for concurrent encrypted wire/codec/obfuscation scratch. |
## 3. HTTP endpoints, public links, and administration
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug listener. Empty disables it. Keep loopback-only; use an SSH tunnel for production profiling. |
| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. |
| `TELESRV_ADMIN_API_ADDR` | nullable address / empty | In-process Admin write API listener. Empty disables it; production should bind loopback. |
| `TELESRV_ADMIN_API_TOKEN` | secret string / empty | Admin API bearer token. Required when the Admin API is enabled and must match the Admin UI token configuration. |
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | Standalone `cmd/telesrv-admin` listen address. |
| `TELESRV_ADMIN_UI_PASSWORD` | secret string / empty | Admin UI login password. Configure this or `TELESRV_ADMIN_UI_TOKEN`. |
| `TELESRV_ADMIN_UI_TOKEN` | secret string / empty | Alternative Admin UI login credential. Admin write calls still use the separate `TELESRV_ADMIN_API_TOKEN`. |
| `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. |
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. |
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. |
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
## 4. PostgreSQL, Redis, files, and seed data
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_POSTGRES_DSN` | secret DSN / `postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable` | Primary durable business database. Production must replace the development credentials and TLS policy. |
| `TELESRV_POSTGRES_MAX_CONNS` | int / `50` | pgxpool maximum connections. `<=0` delegates to pgx defaults, which are usually too small for production outbox/RPC concurrency. |
| `TELESRV_POSTGRES_MIN_CONNS` | int / `16` | pgxpool pre-warmed minimum connections. |
| `TELESRV_REDIS_ADDR` | address / `127.0.0.1:6399` | Redis used for volatile codes, limits, and shared update/cache state. |
| `TELESRV_REDIS_PASSWORD` | secret string / empty | Redis password. |
| `TELESRV_REDIS_DB` | int / `0` | Redis logical database number. |
| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` language-pack seed directory. |
| `TELESRV_BLOB_DIR` | path / `data/blobs` | Local development blob-backend root for media bytes. |
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | Sticker/reaction seed packages imported into documents, sticker sets, and blobs. |
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | Maximum regular sticker sets imported at startup; `<=0` means unlimited. |
## 5. Authentication, login email, SMTP, and passkeys
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | Fixed development login code. Production SMS/risk delivery is not implemented; do not expose this default publicly. |
| `TELESRV_AUTH_CODE_TTL` | duration / `5m` | Login/registration/email verification code lifetime; must be positive. |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | Maximum wrong attempts for one code/hash; must be positive. |
| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | Code issuance limit per normalized phone digest per rate window; `<=0` disables this dimension. |
| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | Code issuance limit per raw auth key per rate window; `<=0` disables this dimension. |
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. |
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification delivery. When true, SMTP settings below become mandatory. |
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | Forces accounts without a login email to configure one. Requires `TELESRV_LOGIN_EMAIL_ENABLE=true`. |
| `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | Email verification-code length; allowed range `4..10`. |
| `TELESRV_SMTP_HOST` | string / empty | SMTP server host; required when login email is enabled. |
| `TELESRV_SMTP_PORT` | int / `587` | SMTP port; must be `1..65535` when login email is enabled. |
| `TELESRV_SMTP_USERNAME` | sensitive string / empty | SMTP username. Also used as sender when `TELESRV_SMTP_FROM` is empty. |
| `TELESRV_SMTP_PASSWORD` | secret string / empty | SMTP password. |
| `TELESRV_SMTP_FROM` | email/string / empty | Envelope/header sender. Either this or SMTP username is required when login email is enabled. |
| `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | Display name for login-email messages. |
| `TELESRV_SMTP_TLS` | enum / `starttls` | `starttls`, `tls`, or `none`; any other value fails startup. |
| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP operation timeout; must be positive when login email is enabled. |
| `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID used for `rpIdHash`. Android Credential Manager requires alignment with hosted `assetlinks.json`. |
| `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / empty | Allowed WebAuthn origins. Empty disables explicit origin enforcement because Android APK-key-hash origins may not be known in advance. |
## 6. Maps, external media, previews, and uploads
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_MAPBOX_TOKEN` | secret string / empty | Mapbox Static Images access token for `upload.getWebFile` map previews. Empty uses deterministic placeholders. |
| `TELESRV_MAPTILE_CACHE_DIR` | path / `data/maptiles` | Disk cache for fetched map thumbnails, preserving byte-stable chunk downloads and limiting quota use. |
| `TELESRV_EXTERNAL_MEDIA_ENABLE` | bool / `true` | Enables SSRF-protected fetching of external photo/document URLs. |
| `TELESRV_EXTERNAL_MEDIA_MAX_BYTES` | int bytes / `10485760` | Maximum response body per external-media fetch. Downstream treats `<=0` as the 10 MiB safe default. |
| `TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN` | int / `60` | Global external-media fetches per minute. Downstream treats `<=0` as its default. |
| `TELESRV_WEBPAGE_PREVIEW_ENABLE` | bool / `true` | Enables SSRF-protected Web-page metadata/image fetching for link previews. |
| `TELESRV_WEBPAGE_PREVIEW_MAX_BYTES` | int bytes / `5242880` | Response cap shared by preview HTML and image fetching. Downstream treats `<=0` as the 5 MiB default. |
| `TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN` | int / `300` | Global preview upstream requests per minute; one preview may make at most two requests. |
| `TELESRV_UPLOAD_PART_TTL` | duration / `24h` | Retention for unassembled upload parts. |
| `TELESRV_UPLOAD_PART_GC_INTERVAL` | duration / `30m` | Upload-part GC polling interval. |
| `TELESRV_UPLOAD_PART_GC_BATCH` | int / `10000` | Maximum rows removed per upload-part GC batch. |
| `TELESRV_UPLOAD_INFLIGHT_MAX_BYTES` | int64 bytes / `4194304000` | Per-user unassembled upload-byte cap; `<=0` means unlimited. |
| `TELESRV_UPLOAD_INFLIGHT_MAX_PARTS` | int / `8000` | Per-user unassembled upload-part row cap; `<=0` means unlimited. |
| `TELESRV_UPLOAD_INFLIGHT_MAX_FILES` | int / `64` | Per-user concurrent unassembled `file_id` cap; `<=0` means unlimited. |
## 7. AI compose and business automation
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business auto-reply generator: `echo`, `template`/`quick_reply`, or `ai`/`compose_ai`/a configured provider name. |
| `TELESRV_AI_ENABLED` | bool / `true` | Enables client compose rewrite/polish. False returns no tones and hides the entry. |
| `TELESRV_AI_PROVIDERS` | list / `local` | Ordered provider chain. Empty resolves to deterministic `local`, which makes no external request. |
| `TELESRV_AI_TIMEOUT` | duration / `15s` | Total timeout for one provider call. |
| `TELESRV_AI_RATE_LIMIT` | int / `20` | Per-account compose operations per window. |
| `TELESRV_AI_RATE_WINDOW` | duration / `1m` | Compose AI rate-limit window. |
| `TELESRV_AI_LOG_CONTENT` | bool / `false` | When false, logs contain lengths/provider/status only. Enabling may expose user prompts and generated text. |
For each name in `TELESRV_AI_PROVIDERS`, telesrv uppercases it, converts non-alphanumeric characters to `_`, and reads the following dynamic keys. Example: provider `openai-compatible` uses suffix `OPENAI_COMPATIBLE`.
| Dynamic setting | Type / default | Description |
|---|---|---|
| `TELESRV_AI_<NAME>_KIND` | string / derived from name | Adapter kind. Built-ins: `local`, `openai_responses`, `openai_chat`, `gemini`, `anthropic`. Names `openai`, `openai_chat`/`openai-compatible`/`openai_compat`, `gemini`, and `anthropic` map to their corresponding built-in kind. |
| `TELESRV_AI_<NAME>_BASE_URL` | URL string / empty | Optional provider endpoint override. Required by some compatible/self-hosted providers. |
| `TELESRV_AI_<NAME>_API_KEY` | secret string / provider fallback | Provider credential. For known providers it falls back to the process variables below. |
| `TELESRV_AI_<NAME>_MODEL` | string / empty | Provider model identifier. External providers generally require it. |
| `TELESRV_AI_<NAME>_MAX_OUTPUT_TOKENS` | int / `1024` | Requested output-token cap. |
| `TELESRV_AI_<NAME>_TEMPERATURE` | float / `0.2` | Sampling temperature. |
| `TELESRV_AI_<NAME>_OMIT_TEMPERATURE` | bool / `false` | Omits the temperature field for models/providers that reject it. |
| `TELESRV_AI_<NAME>_THINKING` | string / empty | Provider-specific thinking/reasoning mode, normalized to lowercase; for example `disabled`. |
The following fallback keys are accepted from the **process environment only**. The env file rejects them because they do not start with `TELESRV_`: `OPENAI_API_KEY`, `GEMINI_API_KEY`, and `ANTHROPIC_API_KEY`. A provider-specific `TELESRV_AI_<NAME>_API_KEY` takes precedence.
## 8. Read-model and auth-key caches
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES` | int / `262144` | Router temporary→permanent auth-key binding cache capacity. |
| `TELESRV_TEMP_KEY_CACHE_TTL` | duration / `30m` | Recheck period; exact bind/revoke invalidation handles normal writes, while TTL covers cross-process/exception paths. |
| `TELESRV_CHANNEL_ROW_CACHE_MAX` | int / `50000` | Shared channel-row cache capacity. `<=0` disables both cache and its LISTEN/NOTIFY listener. |
| `TELESRV_CHANNEL_MEMBER_CACHE_MAX` | int / `100000` | Channel member/access read-model cache capacity; `<=0` disables it. |
| `TELESRV_CHANNEL_DIALOG_CACHE_MAX` | int / `100000` | Viewer/channel dialog projection cache capacity; `<=0` disables it. |
| `TELESRV_CHANNEL_BOOST_CACHE_MAX` | int / `100000` | Channel boost read-model cache capacity; `<=0` disables it. |
| `TELESRV_CHANNEL_BOOST_CACHE_TTL` | duration / `10s` | Maximum stale window if a boost invalidation notification is missed. |
## 9. Outbox, push, limits, retention, and GC
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_OUTBOX_WORKERS` | int / `4` | Concurrent outbox workers. Stable logical sharding preserves per-user pts order. |
| `TELESRV_OUTBOX_BATCH` | int / `100` | Maximum rows claimed per poll. Larger batches improve throughput but increase DB/push bursts. |
| `TELESRV_OUTBOX_INTERVAL` | duration / `200ms` | Delay between outbox claims. |
| `TELESRV_OUTBOX_LEASE_TIMEOUT` | duration / `30s` | Time before a `dispatching` row can be reclaimed. Must exceed worst-case batch delivery time. |
| `TELESRV_OUTBOX_POISON_RETENTION` | duration / `1m` | Diagnostic retention for terminal failed delivery heads; durable update events remain recoverable through difference. |
| `TELESRV_OUTBOX_POISON_CLEANUP_INTERVAL` | duration / `15s` | Cleanup interval for terminal failed heads, independent of large-table retention. |
| `TELESRV_OUTBOUND_PUSH_TIMEOUT` | duration / `200ms` | Maximum wait for best-effort online update enqueue. |
| `TELESRV_SEND_RATE_LIMIT` | int / `30` | Per-account messages per send window; `<=0` disables send limiting. |
| `TELESRV_SEND_RATE_WINDOW` | duration / `1m` | Send-rate window. |
| `TELESRV_CATCHUP_RATE_LIMIT` | int / `0` | Per-user difference/catch-up RPCs per window; `<=0` disables the gate. |
| `TELESRV_CATCHUP_RATE_WINDOW` | duration / `1m` | Catch-up rate-limit window. |
| `TELESRV_CHANNEL_NUDGE_MAX_TARGETS` | int / `0` | Maximum targets for one channel fan-out nudge; `<=0` uses the built-in default. |
| `TELESRV_UPDATE_EVENT_RETENTION` | duration / `168h` | Durable update-log retention. Cleanup only removes events covered by protocol-safe watermarks/state. |
| `TELESRV_BOT_API_UPDATE_RETENTION` | duration / `24h` | Maximum Bot API update queue retention; acknowledged rows also have a shorter fixed grace period. |
| `TELESRV_ORPHAN_AUTH_KEY_RETENTION` | duration / `24h` | Minimum retention for handshake-created keys with no authorization/temp binding/active connection. |
| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | General retention worker interval. |
| `TELESRV_RETENTION_BATCH` | int / `10000` | Maximum rows deleted by one general retention batch. |
## 10. Premium and Stars development grants
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_PREMIUM_GRANT_MONTHS` | int / `3` | Premium months granted to newly registered users; `0` disables new grants. Existing migration backfills are unaffected. |
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | Idempotent lazy starting Stars balance for all accounts; `0` disables automatic grant. |
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | Expired-premium cleanup/push interval. Read paths derive expiry independently. |
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | Maximum expired premium rows processed per sweep. |
## 11. Private calls, group calls, TURN, SFU, and livestream
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_CALL_RING_TIMEOUT` | duration / `90s` | Server fallback timeout for ringing/accepted private calls; should remain aligned with the client `callRingTimeoutMs`. |
| `TELESRV_CALL_TOMBSTONE_TTL` | duration / `60s` | Terminal-call tombstone window for idempotency and late RPC absorption. |
| `TELESRV_CALL_MAX_ACTIVE_PER_USER` | int / `4` | Maximum non-terminal private calls per user. Non-positive values are normalized by the phone service. |
| `TELESRV_CALL_SIGNALING_MAX_BYTES` | int bytes / `65536` | Maximum payload for one `phone.sendSignalingData`. |
| `TELESRV_CALL_SIGNALING_RATE` | int / `50` | Signaling forwards per call per second; excess is silently dropped. |
| `TELESRV_CALL_EXPIRY_INTERVAL` | duration / `1s` | Call-expiry dispatcher polling interval. |
| `TELESRV_GROUPCALL_CHECK_TTL` | duration / `45s` | Participant liveness watermark expiry. Clients and the SFU reporter refresh it. |
| `TELESRV_GROUPCALL_SWEEP_INTERVAL` | duration / `10s` | Ghost-participant sweep interval. |
| `TELESRV_GROUPCALL_MAX_PARTICIPANTS` | int / `32` | Per-room participant cap for the current small-scale implementation. |
| `TELESRV_TURN_ENABLE` | bool / `true` | Enables embedded TURN/STUN relay data in private calls. False falls back to LAN/P2P-only behavior. |
| `TELESRV_TURN_UDP_PORT` | int / `12400` | Embedded TURN/STUN UDP listen port; must differ from the SFU port and be allowed through the firewall. |
| `TELESRV_TURN_ADVERTISE_IP` | string / empty | Client-reachable relay address. Empty falls back to SFU advertise IP, then general advertise IP. |
| `TELESRV_TURN_SECRET` | secret string / empty | HMAC secret for TURN REST credentials. Empty creates a process-random secret; multi-instance/external coturn deployments must configure one stable shared secret. |
| `TELESRV_TURN_RELAY_MIN_PORT` | int / `12500` | Inclusive relay allocation port minimum. |
| `TELESRV_TURN_RELAY_MAX_PORT` | int / `12999` | Inclusive relay allocation port maximum; must not be below the minimum. Open the whole range in the firewall. |
| `TELESRV_CALL_TURN_CREDENTIAL_TTL` | duration / `6h` | Per-call TURN credential lifetime. |
| `TELESRV_CALL_FORCE_RELAY` | bool / `false` | Forces `p2p_allowed=false` to test TURN relay paths. |
| `TELESRV_SFU_ENABLE` | bool / `true` | Enables embedded group-call media forwarding. False leaves signaling-only M0 behavior. |
| `TELESRV_SFU_UDP_PORT` | int / `12399` | Pion ICE UDPMux port; allow it through the firewall. |
| `TELESRV_SFU_ADVERTISE_IP` | string / empty | Client-reachable ICE candidate IP. Empty falls back to `TELESRV_ADVERTISE_IP`; loopback silently breaks real-device media. |
| `TELESRV_LIVESTREAM_ENABLE` | bool / `true` | Enables embedded RTMP ingest plus ffmpeg segmentation for channel livestreams. |
| `TELESRV_LIVESTREAM_RTMP_ADDR` | address / `:2400` | RTMP ingest TCP listen address. |
| `TELESRV_LIVESTREAM_RTMP_URL` | URL string / empty | OBS-facing server URL. Empty derives `rtmp://<AdvertiseIP>:2400/live`. |
| `TELESRV_LIVESTREAM_FFMPEG_PATH` | path/command / `ffmpeg` | ffmpeg executable path; the default resolves through `PATH`. |
| `TELESRV_LIVESTREAM_WORK_DIR` | path / empty | Segment working directory. Empty uses the system temporary directory. |
| `TELESRV_LIVESTREAM_SEGMENT_KEEP` | int seconds / `32` | Per-stream segment duration/window retained in memory; non-positive values are normalized by the livestream service. |
## 12. Production minimum checklist
At minimum, production operators should explicitly review and override the development credentials/endpoints: PostgreSQL DSN and TLS, Redis password/network exposure, RSA key persistence, fixed development auth code exposure, Admin credentials/session key, SMTP secrets when enabled, AI/Mapbox API keys, TURN secret and firewall ports, public URLs/scheme alignment, and non-loopback SFU/TURN advertise addresses for real devices.

221
docs/configuration.zh-CN.md Normal file
View file

@ -0,0 +1,221 @@
# telesrv 配置参数手册
英文版:[configuration.en.md](configuration.en.md)
本文覆盖 `internal/config` 实际读取的全部配置。默认值和校验行为以 `internal/config/config.go` 为权威来源。所有配置修改都需要重启进程;telesrv 当前不支持配置热加载。
## 1. 加载方式、语法与优先级
- `TELESRV_CONFIG` 是选择 env 风格配置文件的**进程环境变量**。默认读取进程工作目录下的 `.env`;显式设为空可关闭文件加载。把它写在配置文件内部不会改变已选定的文件。
- 优先级为:非空进程环境变量 → 非空文件值 → 代码默认值。四个可空监听项 `TELESRV_DEBUG_ADDR`、`TELESRV_BOT_API_ADDR`、`TELESRV_ADMIN_API_ADDR`、`TELESRV_PUBLIC_LINK_WEB_ADDR` 允许用显式空的进程环境变量覆盖文件中的非空值,从而关闭监听。
- 文件支持空行、整行 `#` 注释、可选的 `export ` 前缀和 `KEY=VALUE`;支持单引号、双引号。行尾 `#` 不会被当作内联注释剥离。
- 文件中的键必须以 `TELESRV_` 开头,且只能包含大写 ASCII 字母、数字和下划线。语法合法但当前二进制未知的 `TELESRV_*` 键会被接受但忽略。
- bool 接受 `1/true/TRUE/True/yes/on` 和 `0/false/FALSE/False/no/off`;列表使用逗号分隔;时长使用 Go 格式,例如 `200ms`、`30s`、`5m`、`168h`。
- int、float、bool、duration 的非法文本会回退代码默认值;URL、app scheme、app name 以及登录邮箱依赖关系校验失败会阻止启动。
- 不要提交真实密码、token、私有 DSN 或 TURN secret。生产环境应使用受保护的 service environment 或密钥管理系统。
## 2. MTProto 监听、传输与资源预算
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_LISTEN` | string / `0.0.0.0:2398` | MTProto TCP 监听地址,必须与 patched 客户端可达地址/端口一致。 |
| `TELESRV_ADVERTISE_IP` | string / `127.0.0.1` | 媒体、通话等回退路径使用的客户端可达 IP;当前 TDesktop 静态 DC patch 不从这里获取 MTProto 地址。 |
| `TELESRV_RSA_KEY` | path / `data/server_rsa.pem` | MTProto RSA 私钥;缺失时自动生成。属于敏感文件,重启和升级间必须稳定保存。 |
| `TELESRV_DC` | int / `2` | 服务端 DC ID,必须与客户端 patch 及媒体/DC 元数据一致。 |
| `TELESRV_WEBSOCKET_ENABLE` | bool / `true` | 在 MTProto 监听端口启用 MTProto-over-WebSocket 分流。 |
| `TELESRV_WEBSOCKET_ALLOWED_ORIGINS` | list / `http://localhost:1234,http://127.0.0.1:1234` | 浏览器 WebSocket origin 白名单;`*` 只用于临时调试。 |
| `TELESRV_MTPROTO_MAX_CONNECTIONS` | int / `200000` | 全局物理连接 admission 上限;负数关闭该门禁。 |
| `TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP` | int / `4096` | 单来源 IP 物理连接上限;负数关闭该门禁。 |
| `TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES` | int / `256` | 高成本 RSA/DH 握手并发上限;负数关闭该门禁。 |
| `TELESRV_MTPROTO_RPC_MAX_INFLIGHT` | int / `32` | 单连接同时执行的 RPC 上限;非正值由 edge 归一为安全默认值。 |
| `TELESRV_MTPROTO_RPC_QUEUE_SIZE` | int / `64` | 单连接 RPC 排队容量;非正值使用 edge 默认值。 |
| `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | 调度后 RPC handler 的端到端超时。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | 共享公平调度器 worker 数。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | 进程级排队与执行中的 RPC task 上限。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | 进程级排队/执行中 RPC request body 字节预算。 |
| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | transport wire 与最大解密明文的进程级在途预算,在分配 payload 前预留。 |
| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | 单连接普通 outbound mailbox 容量。 |
| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | 单连接控制消息 mailbox 容量。 |
| `TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | resend pending message body 的全局预算。 |
| `TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | 并发加密 wire/codec/obfuscation scratch 的全局预算。 |
## 3. HTTP 端点、公开链接与管理后台
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug 监听;空值关闭。生产必须保持 loopback,通过 SSH 隧道抓取。 |
| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。 |
| `TELESRV_ADMIN_API_ADDR` | nullable address / 空 | 进程内 Admin 写 API;空值关闭,生产应只监听 loopback。 |
| `TELESRV_ADMIN_API_TOKEN` | secret string / 空 | Admin API bearer token;启用 Admin API 时必须显式配置,并与 Admin UI 使用的 token 一致。 |
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | 独立 `cmd/telesrv-admin` 监听地址。 |
| `TELESRV_ADMIN_UI_PASSWORD` | secret string / 空 | Admin UI 登录密码;它与 `TELESRV_ADMIN_UI_TOKEN` 至少配置一个。 |
| `TELESRV_ADMIN_UI_TOKEN` | secret string / 空 | Admin UI 替代登录凭证;管理写调用仍使用独立的 `TELESRV_ADMIN_API_TOKEN`。 |
| `TELESRV_ADMIN_SESSION_KEY` | secret string / 空 | 加密/签名 Admin UI session cookie;生产至少使用 32 字节随机值,修改会使已有会话失效。 |
| `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | 客户端可见的公开链接根地址;允许 path,禁止 credentials、query、fragment。本地例:`http://127.0.0.1:2401`。 |
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme,必须与 patched 客户端注册值一致;禁止 `tg`、`http`、`https`。 |
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 |
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名;trim 后非空、无控制字符、最多 64 个 Unicode 字符。 |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 |
## 4. PostgreSQL、Redis、文件与 seed
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_POSTGRES_DSN` | secret DSN / `postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable` | 主业务持久库;生产必须替换开发凭证与 TLS 策略。 |
| `TELESRV_POSTGRES_MAX_CONNS` | int / `50` | pgxpool 最大连接数;`<=0` 使用 pgx 默认值,该默认通常不足以覆盖生产 outbox/RPC 并发。 |
| `TELESRV_POSTGRES_MIN_CONNS` | int / `16` | pgxpool 预热最小连接数。 |
| `TELESRV_REDIS_ADDR` | address / `127.0.0.1:6399` | 验证码、限流、共享更新/缓存易失态使用的 Redis。 |
| `TELESRV_REDIS_PASSWORD` | secret string / 空 | Redis 密码。 |
| `TELESRV_REDIS_DB` | int / `0` | Redis 逻辑库编号。 |
| `TELESRV_LANGPACK_SEED_DIR` | path / `data/langpack` | TDesktop `.strings` 语言包 seed 目录。 |
| `TELESRV_BLOB_DIR` | path / `data/blobs` | 本地开发 blob backend 的媒体字节根目录。 |
| `TELESRV_STICKER_SEED_DIR` | path / `data/sticker-seed` | 导入 documents、sticker sets、blob 的贴纸/reaction seed 目录。 |
| `TELESRV_STICKER_SEED_MAX_SETS` | int / `300` | 启动时导入的常规贴纸集上限;`<=0` 表示不限。 |
## 5. 登录、邮箱验证码、SMTP 与 passkey
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_DEV_AUTH_CODE` | sensitive string / `12345` | 固定开发登录码;生产短信/风控尚未接入,不得把默认值暴露在公网环境。 |
| `TELESRV_AUTH_CODE_TTL` | duration / `5m` | 登录/注册/邮箱验证码有效期,必须为正数。 |
| `TELESRV_AUTH_CODE_MAX_ATTEMPTS` | int / `5` | 单 code/hash 最大错误次数,必须为正数。 |
| `TELESRV_AUTH_CODE_PHONE_RATE_LIMIT` | int / `5` | 每个规范化手机号摘要在窗口内的发码上限;`<=0` 关闭该维度。 |
| `TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT` | int / `20` | 每个 raw auth key 在窗口内的发码上限;`<=0` 关闭该维度。 |
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 |
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码投递;开启后 SMTP 配置成为必填。 |
| `TELESRV_LOGIN_EMAIL_REQUIRE_SETUP` | bool / `false` | 强制没有登录邮箱的账号设置邮箱;要求 `TELESRV_LOGIN_EMAIL_ENABLE=true`。 |
| `TELESRV_LOGIN_EMAIL_CODE_LENGTH` | int / `6` | 邮箱验证码长度,允许 `4..10`。 |
| `TELESRV_SMTP_HOST` | string / 空 | SMTP host;启用登录邮箱时必填。 |
| `TELESRV_SMTP_PORT` | int / `587` | SMTP 端口;启用登录邮箱时必须为 `1..65535`。 |
| `TELESRV_SMTP_USERNAME` | sensitive string / 空 | SMTP 用户名;`TELESRV_SMTP_FROM` 为空时也用作发件人。 |
| `TELESRV_SMTP_PASSWORD` | secret string / 空 | SMTP 密码。 |
| `TELESRV_SMTP_FROM` | email/string / 空 | envelope/header 发件人;启用登录邮箱时它与 SMTP username 至少一个非空。 |
| `TELESRV_SMTP_FROM_NAME` | string / `telesrv` | 登录邮件展示的发件人名称。 |
| `TELESRV_SMTP_TLS` | enum / `starttls` | 仅允许 `starttls`、`tls`、`none`,其它值阻止启动。 |
| `TELESRV_SMTP_TIMEOUT` | duration / `10s` | SMTP 操作超时;启用登录邮箱时必须为正数。 |
| `TELESRV_PASSKEY_RP_ID` | hostname / `telesrv.net` | WebAuthn relying-party ID,用于校验 `rpIdHash`;Android Credential Manager 必须与公网 `assetlinks.json` 对齐。 |
| `TELESRV_PASSKEY_ALLOWED_ORIGINS` | list / 空 | WebAuthn origin 白名单;空值不做显式 origin 校验,因为服务端可能无法预知 Android APK-key-hash origin。 |
## 6. 地图、外链媒体、链接预览与上传
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_MAPBOX_TOKEN` | secret string / 空 | `upload.getWebFile` 地图缩略图使用的 Mapbox Static Images token;空值使用确定性占位图。 |
| `TELESRV_MAPTILE_CACHE_DIR` | path / `data/maptiles` | 地图缩略图磁盘缓存,保证分片下载字节稳定并控制上游配额。 |
| `TELESRV_EXTERNAL_MEDIA_ENABLE` | bool / `true` | 启用带 SSRF 防护的外链 photo/document 抓取。 |
| `TELESRV_EXTERNAL_MEDIA_MAX_BYTES` | int bytes / `10485760` | 单次外链媒体响应体上限;下游把 `<=0` 归一为 10 MiB 安全默认值。 |
| `TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN` | int / `60` | 全局每分钟外链媒体抓取数;下游把 `<=0` 归一为默认值。 |
| `TELESRV_WEBPAGE_PREVIEW_ENABLE` | bool / `true` | 启用带 SSRF 防护的网页元数据/图片抓取和链接预览。 |
| `TELESRV_WEBPAGE_PREVIEW_MAX_BYTES` | int bytes / `5242880` | 预览 HTML 与图片抓取共用的响应体上限;下游把 `<=0` 归一为 5 MiB。 |
| `TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN` | int / `300` | 全局每分钟预览上游请求数;一次解析最多产生两次请求。 |
| `TELESRV_UPLOAD_PART_TTL` | duration / `24h` | 未组装上传分片保留期。 |
| `TELESRV_UPLOAD_PART_GC_INTERVAL` | duration / `30m` | upload part GC 轮询间隔。 |
| `TELESRV_UPLOAD_PART_GC_BATCH` | int / `10000` | 单批 upload part GC 最大删除行数。 |
| `TELESRV_UPLOAD_INFLIGHT_MAX_BYTES` | int64 bytes / `4194304000` | 单用户未组装上传字节上限;`<=0` 表示不限。 |
| `TELESRV_UPLOAD_INFLIGHT_MAX_PARTS` | int / `8000` | 单用户未组装分片行数上限;`<=0` 表示不限。 |
| `TELESRV_UPLOAD_INFLIGHT_MAX_FILES` | int / `64` | 单用户并发未组装 `file_id` 上限;`<=0` 表示不限。 |
## 7. AI compose 与 Business automation
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_BUSINESS_AI_PROVIDER` | string / `echo` | Business 自动回复生成器:`echo`、`template`/`quick_reply`,或 `ai`/`compose_ai`/已配置 provider 名。 |
| `TELESRV_AI_ENABLED` | bool / `true` | 启用客户端输入框改写/润色;关闭时返回空 tone 集合并隐藏入口。 |
| `TELESRV_AI_PROVIDERS` | list / `local` | 按顺序尝试的 provider 链;空列表回退确定性 `local`,不访问外网。 |
| `TELESRV_AI_TIMEOUT` | duration / `15s` | 单次 provider 调用总超时。 |
| `TELESRV_AI_RATE_LIMIT` | int / `20` | 单账号每窗口 compose 次数。 |
| `TELESRV_AI_RATE_WINDOW` | duration / `1m` | compose AI 限流窗口。 |
| `TELESRV_AI_LOG_CONTENT` | bool / `false` | false 时日志只写长度/provider/状态;开启可能暴露用户输入和生成文本。 |
对 `TELESRV_AI_PROVIDERS` 中的每个名称,telesrv 会转大写并把非字母数字字符替换为 `_`,再读取下列动态参数。例如 `openai-compatible` 对应 suffix `OPENAI_COMPATIBLE`。
| 动态参数 | 类型 / 默认值 | 说明 |
|---|---|---|
| `TELESRV_AI_<NAME>_KIND` | string / 由名称推导 | adapter 类型。内置值包括 `local`、`openai_responses`、`openai_chat`、`gemini`、`anthropic`;常用名称会自动映射。 |
| `TELESRV_AI_<NAME>_BASE_URL` | URL string / 空 | provider endpoint 覆盖;兼容接口或自托管 provider 通常需要。 |
| `TELESRV_AI_<NAME>_API_KEY` | secret string / provider fallback | provider 凭证;已知 provider 可回退到下述进程环境变量。 |
| `TELESRV_AI_<NAME>_MODEL` | string / 空 | provider model id;外部 provider 通常必填。 |
| `TELESRV_AI_<NAME>_MAX_OUTPUT_TOKENS` | int / `1024` | 请求的输出 token 上限。 |
| `TELESRV_AI_<NAME>_TEMPERATURE` | float / `0.2` | 采样 temperature。 |
| `TELESRV_AI_<NAME>_OMIT_TEMPERATURE` | bool / `false` | 对拒绝 temperature 字段的模型/provider 不发送该字段。 |
| `TELESRV_AI_<NAME>_THINKING` | string / 空 | provider 特定 reasoning/thinking 模式,统一转小写,例如 `disabled`。 |
下列 fallback 只支持**进程环境变量**,因为 env 文件会拒绝不以 `TELESRV_` 开头的键:`OPENAI_API_KEY`、`GEMINI_API_KEY`、`ANTHROPIC_API_KEY`。显式 `TELESRV_AI_<NAME>_API_KEY` 优先级更高。
## 8. Read-model 与 auth-key 缓存
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES` | int / `262144` | Router temp→perm auth-key binding 缓存容量。 |
| `TELESRV_TEMP_KEY_CACHE_TTL` | duration / `30m` | 复核周期;正常写入由 bind/revoke 精确失效,TTL 兜底跨进程/异常路径。 |
| `TELESRV_CHANNEL_ROW_CACHE_MAX` | int / `50000` | 共享 channel row 缓存容量;`<=0` 同时关闭缓存及 LISTEN/NOTIFY listener。 |
| `TELESRV_CHANNEL_MEMBER_CACHE_MAX` | int / `100000` | channel member/access read-model 缓存容量;`<=0` 关闭。 |
| `TELESRV_CHANNEL_DIALOG_CACHE_MAX` | int / `100000` | viewer/channel dialog 投影缓存容量;`<=0` 关闭。 |
| `TELESRV_CHANNEL_BOOST_CACHE_MAX` | int / `100000` | channel boost read-model 缓存容量;`<=0` 关闭。 |
| `TELESRV_CHANNEL_BOOST_CACHE_TTL` | duration / `10s` | boost 失效通知遗漏时允许的最大陈旧窗口。 |
## 9. Outbox、推送、限流、retention 与 GC
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_OUTBOX_WORKERS` | int / `4` | 并发 outbox worker 数;稳定逻辑分片保持单用户 pts 顺序。 |
| `TELESRV_OUTBOX_BATCH` | int / `100` | 每次 poll 最大 claim 行数;增大提高吞吐,也增加 DB/推送突发。 |
| `TELESRV_OUTBOX_INTERVAL` | duration / `200ms` | 两次 outbox claim 之间的等待。 |
| `TELESRV_OUTBOX_LEASE_TIMEOUT` | duration / `30s` | `dispatching` 行可被重新 claim 的超时;必须大于最坏单批投递耗时。 |
| `TELESRV_OUTBOX_POISON_RETENTION` | duration / `1m` | terminal failed 投递头的排障保留窗口;durable update 仍可经 difference 恢复。 |
| `TELESRV_OUTBOX_POISON_CLEANUP_INTERVAL` | duration / `15s` | terminal failed head 清理周期,独立于大表 retention。 |
| `TELESRV_OUTBOUND_PUSH_TIMEOUT` | duration / `200ms` | best-effort 在线 update 入队最长等待。 |
| `TELESRV_SEND_RATE_LIMIT` | int / `30` | 单账号每发送窗口允许的消息数;`<=0` 关闭。 |
| `TELESRV_SEND_RATE_WINDOW` | duration / `1m` | 发送限流窗口。 |
| `TELESRV_CATCHUP_RATE_LIMIT` | int / `0` | 单用户每窗口 difference/catch-up RPC 数;`<=0` 关闭。 |
| `TELESRV_CATCHUP_RATE_WINDOW` | duration / `1m` | catch-up 限流窗口。 |
| `TELESRV_CHANNEL_NUDGE_MAX_TARGETS` | int / `0` | 单次 channel fan-out nudge 目标上限;`<=0` 使用内置默认值。 |
| `TELESRV_UPDATE_EVENT_RETENTION` | duration / `168h` | durable update log 保留期;只删除已被协议安全水位/状态覆盖的事件。 |
| `TELESRV_BOT_API_UPDATE_RETENTION` | duration / `24h` | Bot API update 队列最长保留期;已确认行另有固定短宽限。 |
| `TELESRV_ORPHAN_AUTH_KEY_RETENTION` | duration / `24h` | 没有 authorization/temp binding/活跃连接的握手 auth key 最短保留期。 |
| `TELESRV_RETENTION_INTERVAL` | duration / `1h` | 通用 retention worker 周期。 |
| `TELESRV_RETENTION_BATCH` | int / `10000` | 单次通用 retention 最大删除行数。 |
## 10. Premium 与 Stars 开发赠送
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_PREMIUM_GRANT_MONTHS` | int / `3` | 新注册账号默认 Premium 月数;`0` 关闭新赠送,不影响已有迁移 backfill。 |
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | 对所有账号幂等惰性授予的 Stars 起始余额;`0` 关闭自动赠送。 |
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | 过期 Premium 清理/推送周期;读取路径独立即时派生到期状态。 |
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | 单次 sweep 最大处理行数。 |
## 11. 私聊通话、群通话、TURN、SFU 与直播
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_CALL_RING_TIMEOUT` | duration / `90s` | 私聊通话 ringing/accepted 服务端兜底超时,应与客户端 `callRingTimeoutMs` 保持一致。 |
| `TELESRV_CALL_TOMBSTONE_TTL` | duration / `60s` | 终态通话 tombstone 的幂等/晚到 RPC 吸收窗口。 |
| `TELESRV_CALL_MAX_ACTIVE_PER_USER` | int / `4` | 单用户非终态私聊通话上限;非正值由 phone service 归一。 |
| `TELESRV_CALL_SIGNALING_MAX_BYTES` | int bytes / `65536` | 单条 `phone.sendSignalingData` 载荷上限。 |
| `TELESRV_CALL_SIGNALING_RATE` | int / `50` | 单通话每秒信令转发上限,超限静默丢弃。 |
| `TELESRV_CALL_EXPIRY_INTERVAL` | duration / `1s` | 通话 expiry dispatcher 轮询间隔。 |
| `TELESRV_GROUPCALL_CHECK_TTL` | duration / `45s` | 群通话参与者 liveness 水位过期阈值,客户端与 SFU reporter 都会刷新。 |
| `TELESRV_GROUPCALL_SWEEP_INTERVAL` | duration / `10s` | 幽灵参与者 sweep 周期。 |
| `TELESRV_GROUPCALL_MAX_PARTICIPANTS` | int / `32` | 当前小规模实现的单房间参与者上限。 |
| `TELESRV_TURN_ENABLE` | bool / `true` | 启用内嵌 TURN/STUN 与私聊通话 relay 下发;false 回退 LAN/P2P-only。 |
| `TELESRV_TURN_UDP_PORT` | int / `12400` | 内嵌 TURN/STUN UDP 监听端口;必须与 SFU 端口不同并放行防火墙。 |
| `TELESRV_TURN_ADVERTISE_IP` | string / 空 | 客户端可达 relay IP;空值依次回退 SFU advertise IP、通用 advertise IP。 |
| `TELESRV_TURN_SECRET` | secret string / 空 | TURN REST credential HMAC secret;空值生成进程级随机值,多实例/外部 coturn 必须显式共享稳定值。 |
| `TELESRV_TURN_RELAY_MIN_PORT` | int / `12500` | relay 分配端口范围下界(含)。 |
| `TELESRV_TURN_RELAY_MAX_PORT` | int / `12999` | relay 分配端口范围上界(含),不得小于下界,防火墙需放行整个范围。 |
| `TELESRV_CALL_TURN_CREDENTIAL_TTL` | duration / `6h` | 按通话签发的 TURN credential 有效期。 |
| `TELESRV_CALL_FORCE_RELAY` | bool / `false` | 强制 `p2p_allowed=false`,用于验证 TURN relay 路径。 |
| `TELESRV_SFU_ENABLE` | bool / `true` | 启用内嵌群通话媒体转发;false 保留仅信令 M0 模式。 |
| `TELESRV_SFU_UDP_PORT` | int / `12399` | Pion ICE UDPMux 端口,必须放行防火墙。 |
| `TELESRV_SFU_ADVERTISE_IP` | string / 空 | 下发给客户端的 ICE candidate IP;空值回退 `TELESRV_ADVERTISE_IP`,loopback 会静默破坏真机媒体。 |
| `TELESRV_LIVESTREAM_ENABLE` | bool / `true` | 启用频道 RTMP ingest 与 ffmpeg 切段。 |
| `TELESRV_LIVESTREAM_RTMP_ADDR` | address / `:2400` | RTMP ingest TCP 监听地址。 |
| `TELESRV_LIVESTREAM_RTMP_URL` | URL string / 空 | 返回 OBS 的服务器地址;空值派生 `rtmp://<AdvertiseIP>:2400/live`。 |
| `TELESRV_LIVESTREAM_FFMPEG_PATH` | path/command / `ffmpeg` | ffmpeg 可执行文件路径,默认从 `PATH` 解析。 |
| `TELESRV_LIVESTREAM_WORK_DIR` | path / 空 | segment 临时工作目录;空值使用系统临时目录。 |
| `TELESRV_LIVESTREAM_SEGMENT_KEEP` | int seconds / `32` | 每路直播在内存保留的 segment 秒数/窗口;非正值由 livestream service 归一。 |
## 12. 生产部署最低检查清单
生产至少应显式检查并替换这些开发值:PostgreSQL DSN 与 TLS、Redis 密码和网络暴露、RSA 私钥持久化、固定开发验证码暴露、Admin 凭证/session key、启用邮件时的 SMTP secret、AI/Mapbox API key、TURN secret 与防火墙端口、公开 URL/scheme 与客户端一致性,以及真机所需的非 loopback SFU/TURN advertise IP。

View file

@ -67,6 +67,13 @@ type Config struct {
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
// 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。 // 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。
PublicBaseURL string PublicBaseURL string
// PublicAppScheme 是公开落地页自动唤起自建客户端时使用的 URL scheme。
// 必须与 TDesktop/Android 客户端构建时注册的 scheme 一致,且不能占用 tg/http/https。
PublicAppScheme string
// PublicWebBaseURL 是公开 username 页面“Open in Web”按钮指向的 Web 客户端根 URL。
PublicWebBaseURL string
// PublicAppName 是公开落地页展示的产品名,不参与协议路由。
PublicAppName string
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。 // PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。 // 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
PublicLinkWebAddr string PublicLinkWebAddr string
@ -353,6 +360,18 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, fmt.Errorf("TELESRV_PUBLIC_BASE_URL: %w", err) return Config{}, fmt.Errorf("TELESRV_PUBLIC_BASE_URL: %w", err)
} }
publicAppScheme, err := links.ValidateAppScheme(envOr("TELESRV_PUBLIC_APP_SCHEME", links.DefaultAppScheme))
if err != nil {
return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_SCHEME: %w", err)
}
publicWebBaseURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_WEB_BASE_URL", links.DefaultWebBaseURL))
if err != nil {
return Config{}, fmt.Errorf("TELESRV_PUBLIC_WEB_BASE_URL: %w", err)
}
publicAppName, err := links.ValidateAppName(envOr("TELESRV_PUBLIC_APP_NAME", links.DefaultAppName))
if err != nil {
return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_NAME: %w", err)
}
cfg := Config{ cfg := Config{
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
@ -386,6 +405,9 @@ func Load() (Config, error) {
AdminAPIAddr: envAllowEmptyOr("TELESRV_ADMIN_API_ADDR", ""), AdminAPIAddr: envAllowEmptyOr("TELESRV_ADMIN_API_ADDR", ""),
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
PublicBaseURL: publicBaseURL, PublicBaseURL: publicBaseURL,
PublicAppScheme: publicAppScheme,
PublicWebBaseURL: publicWebBaseURL,
PublicAppName: publicAppName,
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),

View file

@ -22,6 +22,15 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
if cfg.PublicBaseURL != "https://telesrv.net" { if cfg.PublicBaseURL != "https://telesrv.net" {
t.Fatalf("PublicBaseURL = %q, want https://telesrv.net", cfg.PublicBaseURL) t.Fatalf("PublicBaseURL = %q, want https://telesrv.net", cfg.PublicBaseURL)
} }
if cfg.PublicAppScheme != "telesrv" {
t.Fatalf("PublicAppScheme = %q, want telesrv", cfg.PublicAppScheme)
}
if cfg.PublicWebBaseURL != "https://web.telesrv.net" {
t.Fatalf("PublicWebBaseURL = %q, want https://web.telesrv.net", cfg.PublicWebBaseURL)
}
if cfg.PublicAppName != "telesrv" {
t.Fatalf("PublicAppName = %q, want telesrv", cfg.PublicAppName)
}
} }
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
@ -248,6 +257,9 @@ TELESRV_POSTGRES_MAX_CONNS=77
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
TELESRV_CALL_RING_TIMEOUT=2m TELESRV_CALL_RING_TIMEOUT=2m
TELESRV_PUBLIC_BASE_URL=links.example.test/root TELESRV_PUBLIC_BASE_URL=links.example.test/root
TELESRV_PUBLIC_APP_SCHEME=example-chat
TELESRV_PUBLIC_WEB_BASE_URL=web.example.test/client
TELESRV_PUBLIC_APP_NAME=Example Chat
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
`) `)
t.Setenv("TELESRV_CONFIG", path) t.Setenv("TELESRV_CONFIG", path)
@ -274,6 +286,15 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
if cfg.PublicBaseURL != "https://links.example.test/root" { if cfg.PublicBaseURL != "https://links.example.test/root" {
t.Fatalf("PublicBaseURL = %q, want https://links.example.test/root", cfg.PublicBaseURL) t.Fatalf("PublicBaseURL = %q, want https://links.example.test/root", cfg.PublicBaseURL)
} }
if cfg.PublicAppScheme != "example-chat" {
t.Fatalf("PublicAppScheme = %q, want example-chat", cfg.PublicAppScheme)
}
if cfg.PublicWebBaseURL != "https://web.example.test/client" {
t.Fatalf("PublicWebBaseURL = %q, want https://web.example.test/client", cfg.PublicWebBaseURL)
}
if cfg.PublicAppName != "Example Chat" {
t.Fatalf("PublicAppName = %q, want Example Chat", cfg.PublicAppName)
}
} }
func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) { func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
@ -298,6 +319,29 @@ func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
} }
} }
func TestLoadRejectsInvalidPublicLinkClientConfig(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{name: "official scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "tg"},
{name: "malformed scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "bad scheme"},
{name: "invalid web base", key: "TELESRV_PUBLIC_WEB_BASE_URL", value: "file:///tmp/client"},
{name: "empty app name after trim", key: "TELESRV_PUBLIC_APP_NAME", value: " "},
{name: "control in app name", key: "TELESRV_PUBLIC_APP_NAME", value: "bad\nname"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(tc.key, tc.value)
if _, err := Load(); err == nil {
t.Fatalf("Load succeeded with %s=%q", tc.key, tc.value)
}
})
}
}
func TestLoadExplicitEmptyEnvironmentDisablesNullableListeners(t *testing.T) { func TestLoadExplicitEmptyEnvironmentDisablesNullableListeners(t *testing.T) {
path := filepath.Join(t.TempDir(), "telesrv.env") path := filepath.Join(t.TempDir(), "telesrv.env")
writeConfigFile(t, path, ` writeConfigFile(t, path, `

View file

@ -6,9 +6,52 @@ import (
"strings" "strings"
) )
const DefaultPublicBaseURL = "https://telesrv.net" const (
DefaultPublicBaseURL = "https://telesrv.net"
DefaultWebBaseURL = "https://web.telesrv.net"
DefaultAppScheme = "telesrv"
DefaultAppName = "telesrv"
)
const MaxChatlistSlugBytes = 128 const MaxChatlistSlugBytes = 128
// ValidateAppScheme normalizes the client-visible custom URL scheme used by
// public landing pages. Standard Web schemes and Telegram's official tg scheme
// are deliberately rejected: the latter remains a manual compatibility link
// and must never become the automatic open target.
func ValidateAppScheme(raw string) (string, error) {
scheme := strings.ToLower(strings.TrimSpace(raw))
if scheme == "" {
scheme = DefaultAppScheme
}
for i, r := range scheme {
if (r >= 'a' && r <= 'z') || (i > 0 && ((r >= '0' && r <= '9') || r == '+' || r == '-' || r == '.')) {
continue
}
return "", fmt.Errorf("must match [a-z][a-z0-9+.-]*")
}
switch scheme {
case "http", "https", "tg":
return "", fmt.Errorf("reserved scheme %q is not allowed", scheme)
}
return scheme, nil
}
func ValidateAppName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", fmt.Errorf("must not be empty")
}
if len([]rune(name)) > 64 {
return "", fmt.Errorf("must not exceed 64 characters")
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return "", fmt.Errorf("must not contain control characters")
}
}
return name, nil
}
func NormalizeBaseURL(raw string) string { func NormalizeBaseURL(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {

View file

@ -2,6 +2,7 @@ package links
import ( import (
"net/url" "net/url"
"strings"
"testing" "testing"
) )
@ -59,6 +60,45 @@ func TestValidateBaseURL(t *testing.T) {
} }
} }
func TestValidateAppScheme(t *testing.T) {
tests := []struct {
name string
raw string
want string
wantErr bool
}{
{name: "default", raw: "", want: "telesrv"},
{name: "normalized", raw: " My-App+Dev ", want: "my-app+dev"},
{name: "starts with digit", raw: "1app", wantErr: true},
{name: "colon", raw: "myapp:", wantErr: true},
{name: "official tg", raw: "tg", wantErr: true},
{name: "http", raw: "http", wantErr: true},
{name: "https", raw: "https", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ValidateAppScheme(tc.raw)
if (err != nil) != tc.wantErr {
t.Fatalf("ValidateAppScheme(%q) error = %v, wantErr %v", tc.raw, err, tc.wantErr)
}
if got != tc.want {
t.Fatalf("ValidateAppScheme(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestValidateAppName(t *testing.T) {
if got, err := ValidateAppName(" Example Chat "); err != nil || got != "Example Chat" {
t.Fatalf("ValidateAppName valid = %q, %v", got, err)
}
for _, raw := range []string{"", " ", "bad\nname", strings.Repeat("x", 65)} {
if got, err := ValidateAppName(raw); err == nil {
t.Fatalf("ValidateAppName(%q) = %q, want error", raw, got)
}
}
}
func TestBuildPreservesBasePathAndQuery(t *testing.T) { func TestBuildPreservesBasePathAndQuery(t *testing.T) {
got := Build("http://127.0.0.1:2401/root/", "/call/abc", url.Values{"slug": []string{"abc"}}) got := Build("http://127.0.0.1:2401/root/", "/call/abc", url.Values{"slug": []string{"abc"}})
if want := "http://127.0.0.1:2401/root/call/abc?slug=abc"; got != want { if want := "http://127.0.0.1:2401/root/call/abc?slug=abc"; got != want {

View file

@ -20,9 +20,18 @@ import (
appusers "telesrv/internal/app/users" appusers "telesrv/internal/app/users"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/memory" "telesrv/internal/store/memory"
"telesrv/internal/web/stickerlinks" publicweb "telesrv/internal/web"
) )
func newStickerLinkHandler(t *testing.T, files publicweb.StickerSetResolver) http.Handler {
t.Helper()
h, err := publicweb.NewHandler(publicweb.Config{StickerSets: files, PublicBaseURL: "https://telesrv.net"})
if err != nil {
t.Fatalf("new public Web handler: %v", err)
}
return h
}
func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) { func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()
@ -73,7 +82,7 @@ func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) {
t.Fatalf("created = %T, want *tg.MessagesStickerSet", created) t.Fatalf("created = %T, want *tg.MessagesStickerSet", created)
} }
web := stickerlinks.NewHandler(files, "https://telesrv.net") web := newStickerLinkHandler(t, files)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_fresh_pack", nil)) web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_fresh_pack", nil))
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
@ -201,7 +210,7 @@ func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) {
sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102) sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102)
waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON") waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON")
sendStickersBotDocument(t, r, alice, 401, 4401, 9103) sendStickersBotDocument(t, r, alice, 401, 4401, 9103)
waitForStickersReply(t, messageStore, alice.ID, "Now send the emoji") waitForStickersReply(t, messageStore, alice.ID, "emoji")
sendStickersBotText(t, r, alice, "🙂", 9104) sendStickersBotText(t, r, alice, "🙂", 9104)
waitForStickersReply(t, messageStore, alice.ID, "Added") waitForStickersReply(t, messageStore, alice.ID, "Added")
sendStickersBotText(t, r, alice, "/publish", 9105) sendStickersBotText(t, r, alice, "/publish", 9105)
@ -224,7 +233,7 @@ func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) {
t.Fatalf("bob getAllStickers before install = %v, want empty", got) t.Fatalf("bob getAllStickers before install = %v, want empty", got)
} }
web := stickerlinks.NewHandler(files, "https://telesrv.net") web := newStickerLinkHandler(t, files)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_bot_pack", nil)) web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_bot_pack", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "https://telesrv.net/addstickers/alice_bot_pack") { if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "https://telesrv.net/addstickers/alice_bot_pack") {

View file

@ -1,4 +1,5 @@
package stickerlinks // Package web serves telesrv's read-only public link landing pages.
package web
import ( import (
"context" "context"
@ -22,13 +23,17 @@ import (
type Config struct { type Config struct {
Addr string Addr string
PublicBaseURL string PublicBaseURL string
AppScheme string
WebBaseURL string
AppName string
StickerSets StickerSetResolver
Users UsernameResolver Users UsernameResolver
Channels PublicChannelResolver Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver Photos ProfilePhotoResolver
} }
type Resolver interface { type StickerSetResolver interface {
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error)
} }
@ -52,18 +57,18 @@ type ProfilePhotoResolver interface {
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
} }
func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logger) (*http.Server, error) { func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr) addr := strings.TrimSpace(cfg.Addr)
if addr == "" { if addr == "" {
return nil, nil return nil, nil
} }
if resolver == nil {
return nil, fmt.Errorf("sticker links resolver is nil")
}
if logger == nil { if logger == nil {
logger = zap.NewNop() logger = zap.NewNop()
} }
handler := newHandler(resolver, cfg.Users, cfg.Channels, cfg.Privacy, cfg.Photos, cfg.PublicBaseURL, logger) handler, err := newHandler(cfg, logger)
if err != nil {
return nil, err
}
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: handler, Handler: handler,
@ -78,7 +83,11 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
return nil, err return nil, err
} }
go func() { go func() {
logger.Info("Public link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", normalizePublicBaseURL(cfg.PublicBaseURL))) logger.Info("Public link Web endpoint enabled",
zap.String("addr", addr),
zap.String("public_base_url", cfg.PublicBaseURL),
zap.String("app_scheme", cfg.AppScheme),
zap.String("web_base_url", cfg.WebBaseURL))
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Warn("Public link Web endpoint exited", zap.Error(err)) logger.Warn("Public link Web endpoint exited", zap.Error(err))
} }
@ -92,44 +101,46 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
return srv, nil return srv, nil
} }
func NewHandler(resolver Resolver, publicBaseURL string) http.Handler { func NewHandler(cfg Config) (http.Handler, error) {
return newHandler(resolver, nil, nil, nil, nil, publicBaseURL, zap.NewNop()) return newHandler(cfg, zap.NewNop())
} }
func NewHandlerWithUsers(resolver Resolver, users UsernameResolver, publicBaseURL string) http.Handler { func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
return newHandler(resolver, users, nil, nil, nil, publicBaseURL, zap.NewNop()) var err error
} if cfg.StickerSets == nil {
return nil, fmt.Errorf("public Web sticker set resolver is nil")
func NewHandlerWithPublicPeers( }
resolver Resolver, if strings.TrimSpace(cfg.WebBaseURL) == "" {
users UsernameResolver, cfg.WebBaseURL = links.DefaultWebBaseURL
channels PublicChannelResolver, }
privacy AnonymousPrivacyResolver, if strings.TrimSpace(cfg.AppName) == "" {
photos ProfilePhotoResolver, cfg.AppName = links.DefaultAppName
publicBaseURL string, }
) http.Handler { if cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL); err != nil {
return newHandler(resolver, users, channels, privacy, photos, publicBaseURL, zap.NewNop()) return nil, fmt.Errorf("public base URL: %w", err)
} }
if cfg.AppScheme, err = links.ValidateAppScheme(cfg.AppScheme); err != nil {
func newHandler( return nil, fmt.Errorf("app scheme: %w", err)
resolver Resolver, }
users UsernameResolver, if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil {
channels PublicChannelResolver, return nil, fmt.Errorf("Web base URL: %w", err)
privacy AnonymousPrivacyResolver, }
photos ProfilePhotoResolver, if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil {
publicBaseURL string, return nil, fmt.Errorf("app name: %w", err)
logger *zap.Logger, }
) http.Handler {
if logger == nil { if logger == nil {
logger = zap.NewNop() logger = zap.NewNop()
} }
h := &handler{ h := &handler{
resolver: resolver, stickerSets: cfg.StickerSets,
users: users, users: cfg.Users,
channels: channels, channels: cfg.Channels,
privacy: privacy, privacy: cfg.Privacy,
photos: photos, photos: cfg.Photos,
publicBaseURL: normalizePublicBaseURL(publicBaseURL), publicBaseURL: cfg.PublicBaseURL,
appScheme: cfg.AppScheme,
webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
logger: logger, logger: logger,
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@ -140,16 +151,19 @@ func newHandler(
mux.HandleFunc("GET /addlist/{slug}", h.addList) mux.HandleFunc("GET /addlist/{slug}", h.addList)
mux.HandleFunc("GET /{username}", h.usernameLink) mux.HandleFunc("GET /{username}", h.usernameLink)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux) return publicSecurityHeaders(mux), nil
} }
type handler struct { type handler struct {
resolver Resolver stickerSets StickerSetResolver
users UsernameResolver users UsernameResolver
channels PublicChannelResolver channels PublicChannelResolver
privacy AnonymousPrivacyResolver privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver photos ProfilePhotoResolver
publicBaseURL string publicBaseURL string
appScheme string
webBaseURL string
appName string
logger *zap.Logger logger *zap.Logger
} }
@ -172,8 +186,9 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
app := appURL("addlist", "slug", slug) app := h.appURL("addlist", "slug", slug)
data := pageData{ data := pageData{
AppName: h.appName,
Title: "Shared Folder", Title: "Shared Folder",
KindLabel: "shared folder", KindLabel: "shared folder",
Subtitle: slug, Subtitle: slug,
@ -212,13 +227,15 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
return return
} }
params.Set("domain", peer.username) params.Set("domain", peer.username)
app := schemeURLValues("telesrv", "resolve", params) app := schemeURLValues(h.appScheme, "resolve", params)
legacy := schemeURLValues("tg", "resolve", params) legacy := schemeURLValues("tg", "resolve", params)
description := peer.about description := peer.about
if description == "" { if description == "" {
description = peer.fallbackDescription() description = peer.fallbackDescription(h.appName)
} }
data := usernamePageData{ data := usernamePageData{
AppName: h.appName,
AppInitial: appInitial(h.appName),
Title: peer.title, Title: peer.title,
Username: peer.username, Username: peer.username,
Verified: peer.verified, Verified: peer.verified,
@ -228,7 +245,7 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
HomeURL: h.publicBaseURL + "/", HomeURL: h.publicBaseURL + "/",
AppURL: template.URL(app), AppURL: template.URL(app),
LegacyTgURL: template.URL(legacy), LegacyTgURL: template.URL(legacy),
WebURL: template.URL(publicWebAppURL(legacy)), WebURL: template.URL(publicWebAppURL(h.webBaseURL, legacy)),
ButtonLabel: peer.buttonLabel(), ButtonLabel: peer.buttonLabel(),
Initials: peer.initials(), Initials: peer.initials(),
} }
@ -319,7 +336,7 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
set, docs, found, err := h.resolver.ResolveStickerSet(r.Context(), domain.StickerSetRef{ set, docs, found, err := h.stickerSets.ResolveStickerSet(r.Context(), domain.StickerSetRef{
Kind: domain.StickerSetRefByShortName, Kind: domain.StickerSetRefByShortName,
ShortName: shortName, ShortName: shortName,
}) })
@ -340,8 +357,9 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
if count == 0 { if count == 0 {
count = len(docs) count = len(docs)
} }
app := appURL(canonicalKind, "set", set.ShortName) app := h.appURL(canonicalKind, "set", set.ShortName)
data := pageData{ data := pageData{
AppName: h.appName,
Title: fallbackTitle(set), Title: fallbackTitle(set),
KindLabel: kindLabel(set), KindLabel: kindLabel(set),
Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)), Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)),
@ -548,16 +566,16 @@ func (p publicPeer) extra() string {
} }
} }
func (p publicPeer) fallbackDescription() string { func (p publicPeer) fallbackDescription(appName string) string {
switch p.kind { switch p.kind {
case publicPeerBot: case publicPeerBot:
return "Open telesrv to start a chat with this bot." return "Open " + appName + " to start a chat with this bot."
case publicPeerChannel: case publicPeerChannel:
return "Open telesrv to view and join this channel." return "Open " + appName + " to view and join this channel."
case publicPeerSupergroup: case publicPeerSupergroup:
return "Open telesrv to view and join this group." return "Open " + appName + " to view and join this group."
default: default:
return "Open telesrv to send a message to @" + p.username + "." return "Open " + appName + " to send a message to @" + p.username + "."
} }
} }
@ -598,6 +616,13 @@ func plural(n int, one, many string) string {
return many return many
} }
func appInitial(name string) string {
for _, r := range name {
return strings.ToUpper(string(r))
}
return "T"
}
const ( const (
maxPublicLinkRawQuery = 2048 maxPublicLinkRawQuery = 2048
maxPublicLinkParams = 16 maxPublicLinkParams = 16
@ -703,8 +728,8 @@ func schemeURLValues(scheme, kind string, values url.Values) string {
return (&url.URL{Scheme: scheme, Host: kind, RawQuery: values.Encode()}).String() return (&url.URL{Scheme: scheme, Host: kind, RawQuery: values.Encode()}).String()
} }
func publicWebAppURL(legacyURL string) string { func publicWebAppURL(webBaseURL, legacyURL string) string {
return "https://web.telesrv.net/#?tgaddr=" + url.QueryEscape(legacyURL) return strings.TrimRight(webBaseURL, "/") + "/#?tgaddr=" + url.QueryEscape(legacyURL)
} }
func publicSecurityHeaders(next http.Handler) http.Handler { func publicSecurityHeaders(next http.Handler) http.Handler {
@ -718,14 +743,6 @@ func publicSecurityHeaders(next http.Handler) http.Handler {
}) })
} }
func normalizePublicBaseURL(raw string) string {
normalized, err := links.ValidateBaseURL(raw)
if err != nil {
return links.DefaultPublicBaseURL
}
return normalized
}
func validShortNamePath(shortName string) bool { func validShortNamePath(shortName string) bool {
if shortName == "" || len(shortName) > 64 { if shortName == "" || len(shortName) > 64 {
return false return false
@ -803,8 +820,8 @@ func itemNoun(set domain.StickerSet, count int) string {
return "stickers" return "stickers"
} }
func appURL(kind, key, value string) string { func (h *handler) appURL(kind, key, value string) string {
return schemeURL("telesrv", kind, key, value) return schemeURL(h.appScheme, kind, key, value)
} }
func legacyTgURL(kind, key, value string) string { func legacyTgURL(kind, key, value string) string {
@ -816,6 +833,7 @@ func schemeURL(scheme, kind, key, value string) string {
} }
type pageData struct { type pageData struct {
AppName string
Title string Title string
KindLabel string KindLabel string
Subtitle string Subtitle string
@ -827,6 +845,8 @@ type pageData struct {
} }
type usernamePageData struct { type usernamePageData struct {
AppName string
AppInitial string
Title string Title string
Username string Username string
Verified bool Verified bool
@ -850,7 +870,8 @@ func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string)
if err := usernameNotFoundTemplate.Execute(w, struct { if err := usernameNotFoundTemplate.Execute(w, struct {
Username string Username string
HomeURL string HomeURL string
}{Username: username, HomeURL: h.publicBaseURL + "/"}); err != nil { AppName string
}{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName}); err != nil {
h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err)) h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err))
} }
} }
@ -861,12 +882,12 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0e1621"> <meta name="theme-color" content="#0e1621">
<title>{{.Title}} (@{{.Username}}) - telesrv</title> <title>{{.Title}} (@{{.Username}}) - {{.AppName}}</title>
<meta name="description" content="{{.Description}}"> <meta name="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large"> <meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:type" content="profile"> <meta property="og:type" content="profile">
<meta property="og:site_name" content="telesrv"> <meta property="og:site_name" content="{{.AppName}}">
<meta property="og:title" content="{{.Title}}"> <meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}"> <meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}"> <meta property="og:url" content="{{.CanonicalURL}}">
@ -922,7 +943,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
<a class="brand" href="{{.HomeURL}}" aria-label="telesrv home"><span class="brand-mark">t</span><span>telesrv</span></a> <a class="brand" href="{{.HomeURL}}" aria-label="{{.AppName}} home"><span class="brand-mark">{{.AppInitial}}</span><span>{{.AppName}}</span></a>
<main> <main>
<article class="card"> <article class="card">
<div class="avatar">{{if .PhotoURL}}<img src="{{.PhotoURL}}" alt="{{.Title}} profile photo" width="112" height="112">{{else}}<span class="initials" aria-hidden="true">{{.Initials}}</span>{{end}}</div> <div class="avatar">{{if .PhotoURL}}<img src="{{.PhotoURL}}" alt="{{.Title}} profile photo" width="112" height="112">{{else}}<span class="initials" aria-hidden="true">{{.Initials}}</span>{{end}}</div>
@ -937,7 +958,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
<p class="legacy">Old test clients only: <a href="{{.LegacyTgURL}}">open with tg://</a></p> <p class="legacy">Old test clients only: <a href="{{.LegacyTgURL}}">open with tg://</a></p>
</article> </article>
</main> </main>
<footer>If you have telesrv, this page can open the chat directly.</footer> <footer>If you have {{.AppName}}, this page can open the chat directly.</footer>
</div> </div>
<script>window.setTimeout(function () { window.location.href = {{.AppURLJS}}; }, 250);</script> <script>window.setTimeout(function () { window.location.href = {{.AppURLJS}}; }, 250);</script>
</body> </body>
@ -946,16 +967,16 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(`<!doctype html> var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>Username not found - telesrv</title> <meta name="robots" content="noindex,nofollow"><title>Username not found - {{.AppName}}</title>
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:#0e1621;color:#f5f8fb}.card{width:min(100%,420px);padding:34px 28px;border:1px solid rgba(255,255,255,.08);border-radius:22px;background:#17212b;text-align:center}h1{margin:0 0 12px;font-size:26px}p{margin:0;color:#9fb0bf;line-height:1.55;overflow-wrap:anywhere}a{display:inline-block;margin-top:24px;color:#67bff9;text-decoration:none}</style> <style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:#0e1621;color:#f5f8fb}.card{width:min(100%,420px);padding:34px 28px;border:1px solid rgba(255,255,255,.08);border-radius:22px;background:#17212b;text-align:center}h1{margin:0 0 12px;font-size:26px}p{margin:0;color:#9fb0bf;line-height:1.55;overflow-wrap:anywhere}a{display:inline-block;margin-top:24px;color:#67bff9;text-decoration:none}</style>
</head><body><main class="card"><h1>Username not found</h1><p>{{if .Username}}@{{.Username}} is not an active public telesrv username.{{else}}This is not a valid public telesrv username.{{end}}</p><a href="{{.HomeURL}}">Back to telesrv</a></main></body></html>`)) </head><body><main class="card"><h1>Username not found</h1><p>{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}</p><a href="{{.HomeURL}}">Back to {{.AppName}}</a></main></body></html>`))
var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype html> var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - telesrv</title> <title>{{.Title}} - {{.AppName}}</title>
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}}"> <meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}"> <meta property="og:description" content="{{.Description}}">
@ -984,7 +1005,7 @@ var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype htm
<p class="meta">{{.KindLabel}}</p> <p class="meta">{{.KindLabel}}</p>
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
<p class="meta">{{.Subtitle}}</p> <p class="meta">{{.Subtitle}}</p>
<p><a class="button" href="{{.AppURL}}">Open in telesrv</a></p> <p><a class="button" href="{{.AppURL}}">Open in {{.AppName}}</a></p>
<p>{{.Description}}</p> <p>{{.Description}}</p>
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p> <p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p> <p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>

View file

@ -1,4 +1,4 @@
package stickerlinks package web
import ( import (
"context" "context"
@ -11,6 +11,39 @@ import (
"telesrv/internal/domain" "telesrv/internal/domain"
) )
func newTestHandler(t *testing.T, resolver StickerSetResolver, publicBaseURL string) http.Handler {
t.Helper()
h, err := NewHandler(Config{StickerSets: resolver, PublicBaseURL: publicBaseURL})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
return h
}
func newTestHandlerWithPublicPeers(
t *testing.T,
resolver StickerSetResolver,
users UsernameResolver,
channels PublicChannelResolver,
privacy AnonymousPrivacyResolver,
photos ProfilePhotoResolver,
publicBaseURL string,
) http.Handler {
t.Helper()
h, err := NewHandler(Config{
StickerSets: resolver,
Users: users,
Channels: channels,
Privacy: privacy,
Photos: photos,
PublicBaseURL: publicBaseURL,
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
return h
}
func TestHandlerServesStickerSetLandingPage(t *testing.T) { func TestHandlerServesStickerSetLandingPage(t *testing.T) {
resolver := fakeResolver{ resolver := fakeResolver{
"fresh_pack": { "fresh_pack": {
@ -24,7 +57,7 @@ func TestHandlerServesStickerSetLandingPage(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil) req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil)
NewHandler(resolver, "https://telesrv.net/").ServeHTTP(rr, req) newTestHandler(t, resolver, "https://telesrv.net/").ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@ -63,7 +96,7 @@ func TestHandlerServesEmojiLandingPage(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addemoji/emoji_pack", nil) req := httptest.NewRequest(http.MethodGet, "/addemoji/emoji_pack", nil)
NewHandler(resolver, "https://example.test/base").ServeHTTP(rr, req) newTestHandler(t, resolver, "https://example.test/base").ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@ -85,7 +118,7 @@ func TestHandlerServesChatlistLandingPage(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addlist/zNhytIbwRwjaC2GH", nil) req := httptest.NewRequest(http.MethodGet, "/addlist/zNhytIbwRwjaC2GH", nil)
NewHandler(fakeResolver{}, "http://127.0.0.1:2401").ServeHTTP(rr, req) newTestHandler(t, fakeResolver{}, "http://127.0.0.1:2401").ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@ -119,7 +152,7 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/TetrisBot", nil) req := httptest.NewRequest(http.MethodGet, "/TetrisBot", nil)
NewHandlerWithPublicPeers(fakeResolver{}, users, nil, nil, nil, "http://127.0.0.1:2401").ServeHTTP(rr, req) newTestHandlerWithPublicPeers(t, fakeResolver{}, users, nil, nil, nil, "http://127.0.0.1:2401").ServeHTTP(rr, req)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@ -146,6 +179,74 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) {
} }
} }
func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
h, err := NewHandler(Config{
StickerSets: fakeResolver{
"stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers},
"emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true},
},
Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}},
PublicBaseURL: "https://links.example.test",
AppScheme: "example-chat",
WebBaseURL: "https://web.example.test/client/",
AppName: "Example Chat",
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/Alice?start=hello", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"example-chat://resolve?domain=Alice&amp;start=hello",
"https://web.example.test/client/#?tgaddr=",
"Example Chat",
"Open Example Chat to send a message to @Alice.",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q:\n%s", want, body)
}
}
if strings.Contains(body, "telesrv://") || strings.Contains(body, "https://web.telesrv.net") {
t.Fatalf("body contains stale default client link:\n%s", body)
}
for _, tc := range []struct {
path string
want string
}{
{path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"},
{path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"},
{path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"},
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), tc.want) || !strings.Contains(rr.Body.String(), "Example Chat") {
t.Fatalf("%s response = %d %q, want configured link %q and brand", tc.path, rr.Code, rr.Body.String(), tc.want)
}
}
}
func TestNewHandlerRejectsInvalidClientLinkConfig(t *testing.T) {
for _, tc := range []struct {
name string
cfg Config
}{
{name: "missing sticker resolver", cfg: Config{}},
{name: "official scheme", cfg: Config{StickerSets: fakeResolver{}, AppScheme: "tg"}},
{name: "invalid Web base URL", cfg: Config{StickerSets: fakeResolver{}, WebBaseURL: "file:///tmp/web"}},
{name: "invalid app name", cfg: Config{StickerSets: fakeResolver{}, AppName: "bad\nname"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := NewHandler(tc.cfg); err == nil {
t.Fatal("NewHandler succeeded, want error")
}
})
}
}
func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) {
users := fakeUsers{ users := fakeUsers{
"alice": { "alice": {
@ -183,7 +284,7 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) {
photos := &fakePhotos{byID: map[int64]domain.Photo{ photos := &fakePhotos{byID: map[int64]domain.Photo{
301: {ID: 301, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}}, 301: {ID: 301, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}},
}} }}
handler := NewHandlerWithPublicPeers(fakeResolver{}, users, channels, nil, photos, "https://telesrv.net") handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, users, channels, nil, photos, "https://telesrv.net")
for _, tc := range []struct { for _, tc := range []struct {
path string path string
@ -233,7 +334,7 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) {
} }
func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) { func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"tetrisbot": {ID: 2001, Username: "TetrisBot", FirstName: "Tetris", Bot: true}, "tetrisbot": {ID: 2001, Username: "TetrisBot", FirstName: "Tetris", Bot: true},
}, nil, nil, nil, "https://telesrv.net") }, nil, nil, nil, "https://telesrv.net")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -283,7 +384,7 @@ func TestHandlerHonorsAnonymousAboutAndPhotoPrivacy(t *testing.T) {
domain.PrivacyKeyAbout: false, domain.PrivacyKeyAbout: false,
domain.PrivacyKeyProfilePhoto: false, domain.PrivacyKeyProfilePhoto: false,
} }
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"}, "alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"},
}, nil, privacy, photos, "https://telesrv.net") }, nil, privacy, photos, "https://telesrv.net")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -314,7 +415,7 @@ func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) {
"photo:99:c": {Bytes: jpeg, MimeType: "image/jpeg", Total: int64(len(jpeg))}, "photo:99:c": {Bytes: jpeg, MimeType: "image/jpeg", Total: int64(len(jpeg))},
}, },
} }
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice"}, "alice": {ID: userID, Username: "Alice", FirstName: "Alice"},
}, nil, nil, photos, "https://telesrv.net") }, nil, nil, photos, "https://telesrv.net")
@ -345,7 +446,7 @@ func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) {
} }
func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) { func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"}, "sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"},
}, fakeChannels{ }, fakeChannels{
"sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true}, "sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true},
@ -358,7 +459,7 @@ func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) {
} }
func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) { func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{}, fakeChannels{}, nil, nil, "https://telesrv.net") handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{}, fakeChannels{}, nil, nil, "https://telesrv.net")
for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} { for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
@ -394,7 +495,7 @@ func TestPublicAvatarRejectsOversizedOrUnsafeBlob(t *testing.T) {
}, },
files: map[string]domain.FileChunk{"photo:99:c": tc.chunk}, files: map[string]domain.FileChunk{"photo:99:c": tc.chunk},
} }
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice"}, "alice": {ID: userID, Username: "Alice", FirstName: "Alice"},
}, nil, nil, photos, "https://telesrv.net") }, nil, nil, photos, "https://telesrv.net")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -419,7 +520,7 @@ func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addstickers/emoji_pack", nil) req := httptest.NewRequest(http.MethodGet, "/addstickers/emoji_pack", nil)
NewHandler(resolver, "https://telesrv.net").ServeHTTP(rr, req) newTestHandler(t, resolver, "https://telesrv.net").ServeHTTP(rr, req)
if rr.Code != http.StatusPermanentRedirect { if rr.Code != http.StatusPermanentRedirect {
t.Fatalf("status = %d, want 308; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 308; body=%s", rr.Code, rr.Body.String())
@ -430,7 +531,7 @@ func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
} }
func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) { func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{
"alice": { "alice": {
ID: 2001, ID: 2001,
Username: "Alice", Username: "Alice",
@ -460,7 +561,7 @@ func TestHandlerLookupErrorIsInternalServerError(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil) req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil)
NewHandler(errorResolver{}, "https://telesrv.net").ServeHTTP(rr, req) newTestHandler(t, errorResolver{}, "https://telesrv.net").ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError { if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rr.Code) t.Fatalf("status = %d, want 500", rr.Code)