From c0088f1160c5d0fcfcf22f82a213dba103c238d6 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 11 Jul 2026 20:34:10 +0800 Subject: [PATCH] docs: sync public link configuration reference --- .env.example | 11 + README.md | 12 +- README.zh-CN.md | 12 +- cmd/telesrv/main.go | 12 +- docs/configuration.en.md | 221 ++++++++++++++++++ docs/configuration.zh-CN.md | 221 ++++++++++++++++++ internal/config/config.go | 22 ++ internal/config/config_test.go | 44 ++++ internal/links/links.go | 45 +++- internal/links/links_test.go | 40 ++++ internal/rpc/sticker_link_smoke_test.go | 17 +- internal/web/{stickerlinks => }/server.go | 169 ++++++++------ .../web/{stickerlinks => }/server_test.go | 131 +++++++++-- 13 files changed, 857 insertions(+), 100 deletions(-) create mode 100644 docs/configuration.en.md create mode 100644 docs/configuration.zh-CN.md rename internal/web/{stickerlinks => }/server.go (88%) rename internal/web/{stickerlinks => }/server_test.go (78%) diff --git a/.env.example b/.env.example index 30703da4..1330e662 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Optional local config file for telesrv. # 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_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. 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 配置 # # TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token, diff --git a/README.md b/README.md index 4b09a470..a9e821ef 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,14 @@ workers in the same process. 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 | |---|---:|---| | `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_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 | @@ -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_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_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_REDIS_ADDR` | `127.0.0.1:6399` | Redis address | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory | @@ -185,6 +192,9 @@ links: ```env 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index f3e05ab6..c46321d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP | +| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 媒体与通话使用的客户端可达回退 IP | | `TELESRV_DC` | `2` | 自建 DC id | | `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 | | `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_SMTP_HOST` | 空 | 开启登录邮箱验证时使用的 SMTP host | | `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_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 | @@ -173,6 +180,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 ```env 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 diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index e7c151cc..e2d9e627 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -64,7 +64,7 @@ import ( "telesrv/internal/store/postgres" "telesrv/internal/store/redisstore" "telesrv/internal/turnsrv" - "telesrv/internal/web/stickerlinks" + "telesrv/internal/web" ) 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 { 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, PublicBaseURL: cfg.PublicBaseURL, + AppScheme: cfg.PublicAppScheme, + WebBaseURL: cfg.PublicWebBaseURL, + AppName: cfg.PublicAppName, + StickerSets: filesService, Users: userStore, Channels: channelStore, Privacy: privacyService, Photos: filesService, - }, filesService, logger.Named("stickerlinks")); err != nil { - return fmt.Errorf("start sticker links: %w", err) + }, logger.Named("public-web")); err != nil { + return fmt.Errorf("start public Web: %w", err) } srv := mtprotoedge.New(mtprotoedge.Options{ diff --git a/docs/configuration.en.md b/docs/configuration.en.md new file mode 100644 index 00000000..d53ccb87 --- /dev/null +++ b/docs/configuration.en.md @@ -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__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__BASE_URL` | URL string / empty | Optional provider endpoint override. Required by some compatible/self-hosted providers. | +| `TELESRV_AI__API_KEY` | secret string / provider fallback | Provider credential. For known providers it falls back to the process variables below. | +| `TELESRV_AI__MODEL` | string / empty | Provider model identifier. External providers generally require it. | +| `TELESRV_AI__MAX_OUTPUT_TOKENS` | int / `1024` | Requested output-token cap. | +| `TELESRV_AI__TEMPERATURE` | float / `0.2` | Sampling temperature. | +| `TELESRV_AI__OMIT_TEMPERATURE` | bool / `false` | Omits the temperature field for models/providers that reject it. | +| `TELESRV_AI__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__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://: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. diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md new file mode 100644 index 00000000..54fb546c --- /dev/null +++ b/docs/configuration.zh-CN.md @@ -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__KIND` | string / 由名称推导 | adapter 类型。内置值包括 `local`、`openai_responses`、`openai_chat`、`gemini`、`anthropic`;常用名称会自动映射。 | +| `TELESRV_AI__BASE_URL` | URL string / 空 | provider endpoint 覆盖;兼容接口或自托管 provider 通常需要。 | +| `TELESRV_AI__API_KEY` | secret string / provider fallback | provider 凭证;已知 provider 可回退到下述进程环境变量。 | +| `TELESRV_AI__MODEL` | string / 空 | provider model id;外部 provider 通常必填。 | +| `TELESRV_AI__MAX_OUTPUT_TOKENS` | int / `1024` | 请求的输出 token 上限。 | +| `TELESRV_AI__TEMPERATURE` | float / `0.2` | 采样 temperature。 | +| `TELESRV_AI__OMIT_TEMPERATURE` | bool / `false` | 对拒绝 temperature 字段的模型/provider 不发送该字段。 | +| `TELESRV_AI__THINKING` | string / 空 | provider 特定 reasoning/thinking 模式,统一转小写,例如 `disabled`。 | + +下列 fallback 只支持**进程环境变量**,因为 env 文件会拒绝不以 `TELESRV_` 开头的键:`OPENAI_API_KEY`、`GEMINI_API_KEY`、`ANTHROPIC_API_KEY`。显式 `TELESRV_AI__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://: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。 diff --git a/internal/config/config.go b/internal/config/config.go index 0f77365d..5d488669 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,13 @@ type Config struct { // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 // 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。 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 是公开链接落地页监听地址;为空关闭。 // 生产应只监听 loopback,并由 nginx 将 /、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。 PublicLinkWebAddr string @@ -353,6 +360,18 @@ func Load() (Config, error) { if err != nil { 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{ ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), @@ -386,6 +405,9 @@ func Load() (Config, error) { AdminAPIAddr: envAllowEmptyOr("TELESRV_ADMIN_API_ADDR", ""), AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), PublicBaseURL: publicBaseURL, + PublicAppScheme: publicAppScheme, + PublicWebBaseURL: publicWebBaseURL, + PublicAppName: publicAppName, PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 783058a2..c04d26e6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,15 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { if cfg.PublicBaseURL != "https://telesrv.net" { 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) { @@ -248,6 +257,9 @@ TELESRV_POSTGRES_MAX_CONNS=77 TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example TELESRV_CALL_RING_TIMEOUT=2m 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 `) 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" { 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) { @@ -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) { path := filepath.Join(t.TempDir(), "telesrv.env") writeConfigFile(t, path, ` diff --git a/internal/links/links.go b/internal/links/links.go index fdc02112..50ac7a2f 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -6,9 +6,52 @@ import ( "strings" ) -const DefaultPublicBaseURL = "https://telesrv.net" +const ( + DefaultPublicBaseURL = "https://telesrv.net" + DefaultWebBaseURL = "https://web.telesrv.net" + DefaultAppScheme = "telesrv" + DefaultAppName = "telesrv" +) 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 { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/links/links_test.go b/internal/links/links_test.go index d0c06049..14ca808e 100644 --- a/internal/links/links_test.go +++ b/internal/links/links_test.go @@ -2,6 +2,7 @@ package links import ( "net/url" + "strings" "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) { 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 { diff --git a/internal/rpc/sticker_link_smoke_test.go b/internal/rpc/sticker_link_smoke_test.go index b6b9a5fd..4c51b172 100644 --- a/internal/rpc/sticker_link_smoke_test.go +++ b/internal/rpc/sticker_link_smoke_test.go @@ -20,9 +20,18 @@ import ( appusers "telesrv/internal/app/users" "telesrv/internal/domain" "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) { ctx := context.Background() userStore := memory.NewUserStore() @@ -73,7 +82,7 @@ func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) { t.Fatalf("created = %T, want *tg.MessagesStickerSet", created) } - web := stickerlinks.NewHandler(files, "https://telesrv.net") + web := newStickerLinkHandler(t, files) rr := httptest.NewRecorder() web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_fresh_pack", nil)) if rr.Code != http.StatusOK { @@ -201,7 +210,7 @@ func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) { sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102) waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON") 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) waitForStickersReply(t, messageStore, alice.ID, "Added") 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) } - web := stickerlinks.NewHandler(files, "https://telesrv.net") + web := newStickerLinkHandler(t, files) rr := httptest.NewRecorder() 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") { diff --git a/internal/web/stickerlinks/server.go b/internal/web/server.go similarity index 88% rename from internal/web/stickerlinks/server.go rename to internal/web/server.go index 67c80654..9cbcad7f 100644 --- a/internal/web/stickerlinks/server.go +++ b/internal/web/server.go @@ -1,4 +1,5 @@ -package stickerlinks +// Package web serves telesrv's read-only public link landing pages. +package web import ( "context" @@ -22,13 +23,17 @@ import ( type Config struct { Addr string PublicBaseURL string + AppScheme string + WebBaseURL string + AppName string + StickerSets StickerSetResolver Users UsernameResolver Channels PublicChannelResolver Privacy AnonymousPrivacyResolver Photos ProfilePhotoResolver } -type Resolver interface { +type StickerSetResolver interface { 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) } -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) if addr == "" { return nil, nil } - if resolver == nil { - return nil, fmt.Errorf("sticker links resolver is nil") - } if logger == nil { 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{ Addr: addr, Handler: handler, @@ -78,7 +83,11 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge return nil, err } 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) { 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 } -func NewHandler(resolver Resolver, publicBaseURL string) http.Handler { - return newHandler(resolver, nil, nil, nil, nil, publicBaseURL, zap.NewNop()) +func NewHandler(cfg Config) (http.Handler, error) { + return newHandler(cfg, zap.NewNop()) } -func NewHandlerWithUsers(resolver Resolver, users UsernameResolver, publicBaseURL string) http.Handler { - return newHandler(resolver, users, nil, nil, nil, publicBaseURL, zap.NewNop()) -} - -func NewHandlerWithPublicPeers( - resolver Resolver, - users UsernameResolver, - channels PublicChannelResolver, - privacy AnonymousPrivacyResolver, - photos ProfilePhotoResolver, - publicBaseURL string, -) http.Handler { - return newHandler(resolver, users, channels, privacy, photos, publicBaseURL, zap.NewNop()) -} - -func newHandler( - resolver Resolver, - users UsernameResolver, - channels PublicChannelResolver, - privacy AnonymousPrivacyResolver, - photos ProfilePhotoResolver, - publicBaseURL string, - logger *zap.Logger, -) http.Handler { +func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { + var err error + if cfg.StickerSets == nil { + return nil, fmt.Errorf("public Web sticker set resolver is nil") + } + if strings.TrimSpace(cfg.WebBaseURL) == "" { + cfg.WebBaseURL = links.DefaultWebBaseURL + } + if strings.TrimSpace(cfg.AppName) == "" { + cfg.AppName = links.DefaultAppName + } + if cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL); err != nil { + return nil, fmt.Errorf("public base URL: %w", err) + } + if cfg.AppScheme, err = links.ValidateAppScheme(cfg.AppScheme); err != nil { + return nil, fmt.Errorf("app scheme: %w", err) + } + if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil { + return nil, fmt.Errorf("Web base URL: %w", err) + } + if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil { + return nil, fmt.Errorf("app name: %w", err) + } if logger == nil { logger = zap.NewNop() } h := &handler{ - resolver: resolver, - users: users, - channels: channels, - privacy: privacy, - photos: photos, - publicBaseURL: normalizePublicBaseURL(publicBaseURL), + stickerSets: cfg.StickerSets, + users: cfg.Users, + channels: cfg.Channels, + privacy: cfg.Privacy, + photos: cfg.Photos, + publicBaseURL: cfg.PublicBaseURL, + appScheme: cfg.AppScheme, + webBaseURL: cfg.WebBaseURL, + appName: cfg.AppName, logger: logger, } mux := http.NewServeMux() @@ -140,16 +151,19 @@ func newHandler( mux.HandleFunc("GET /addlist/{slug}", h.addList) mux.HandleFunc("GET /{username}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink) - return publicSecurityHeaders(mux) + return publicSecurityHeaders(mux), nil } type handler struct { - resolver Resolver + stickerSets StickerSetResolver users UsernameResolver channels PublicChannelResolver privacy AnonymousPrivacyResolver photos ProfilePhotoResolver publicBaseURL string + appScheme string + webBaseURL string + appName string logger *zap.Logger } @@ -172,8 +186,9 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - app := appURL("addlist", "slug", slug) + app := h.appURL("addlist", "slug", slug) data := pageData{ + AppName: h.appName, Title: "Shared Folder", KindLabel: "shared folder", Subtitle: slug, @@ -212,13 +227,15 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { return } params.Set("domain", peer.username) - app := schemeURLValues("telesrv", "resolve", params) + app := schemeURLValues(h.appScheme, "resolve", params) legacy := schemeURLValues("tg", "resolve", params) description := peer.about if description == "" { - description = peer.fallbackDescription() + description = peer.fallbackDescription(h.appName) } data := usernamePageData{ + AppName: h.appName, + AppInitial: appInitial(h.appName), Title: peer.title, Username: peer.username, Verified: peer.verified, @@ -228,7 +245,7 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { HomeURL: h.publicBaseURL + "/", AppURL: template.URL(app), LegacyTgURL: template.URL(legacy), - WebURL: template.URL(publicWebAppURL(legacy)), + WebURL: template.URL(publicWebAppURL(h.webBaseURL, legacy)), ButtonLabel: peer.buttonLabel(), Initials: peer.initials(), } @@ -319,7 +336,7 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri http.NotFound(w, r) 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, ShortName: shortName, }) @@ -340,8 +357,9 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri if count == 0 { count = len(docs) } - app := appURL(canonicalKind, "set", set.ShortName) + app := h.appURL(canonicalKind, "set", set.ShortName) data := pageData{ + AppName: h.appName, Title: fallbackTitle(set), KindLabel: kindLabel(set), 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 { case publicPeerBot: - return "Open telesrv to start a chat with this bot." + return "Open " + appName + " to start a chat with this bot." case publicPeerChannel: - return "Open telesrv to view and join this channel." + return "Open " + appName + " to view and join this channel." case publicPeerSupergroup: - return "Open telesrv to view and join this group." + return "Open " + appName + " to view and join this group." 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 } +func appInitial(name string) string { + for _, r := range name { + return strings.ToUpper(string(r)) + } + return "T" +} + const ( maxPublicLinkRawQuery = 2048 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() } -func publicWebAppURL(legacyURL string) string { - return "https://web.telesrv.net/#?tgaddr=" + url.QueryEscape(legacyURL) +func publicWebAppURL(webBaseURL, legacyURL string) string { + return strings.TrimRight(webBaseURL, "/") + "/#?tgaddr=" + url.QueryEscape(legacyURL) } 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 { if shortName == "" || len(shortName) > 64 { return false @@ -803,8 +820,8 @@ func itemNoun(set domain.StickerSet, count int) string { return "stickers" } -func appURL(kind, key, value string) string { - return schemeURL("telesrv", kind, key, value) +func (h *handler) appURL(kind, key, value string) string { + return schemeURL(h.appScheme, kind, key, value) } func legacyTgURL(kind, key, value string) string { @@ -816,6 +833,7 @@ func schemeURL(scheme, kind, key, value string) string { } type pageData struct { + AppName string Title string KindLabel string Subtitle string @@ -827,6 +845,8 @@ type pageData struct { } type usernamePageData struct { + AppName string + AppInitial string Title string Username string Verified bool @@ -850,7 +870,8 @@ func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) if err := usernameNotFoundTemplate.Execute(w, struct { Username 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)) } } @@ -861,12 +882,12 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par - {{.Title}} (@{{.Username}}) - telesrv + {{.Title}} (@{{.Username}}) - {{.AppName}} - + @@ -922,7 +943,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
- ttelesrv + {{.AppInitial}}{{.AppName}}
{{if .PhotoURL}}{{.Title}} profile photo{{else}}{{end}}
@@ -937,7 +958,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par

Old test clients only: open with tg://

- +
@@ -946,16 +967,16 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(` -Username not found - telesrv +Username not found - {{.AppName}} -

Username not found

{{if .Username}}@{{.Username}} is not an active public telesrv username.{{else}}This is not a valid public telesrv username.{{end}}

Back to telesrv
`)) +

Username not found

{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}

Back to {{.AppName}}
`)) var landingTemplate = template.Must(template.New("landing").Parse(` - {{.Title}} - telesrv + {{.Title}} - {{.AppName}} @@ -984,7 +1005,7 @@ var landingTemplate = template.Must(template.New("landing").Parse(`{{.KindLabel}}

{{.Title}}

{{.Subtitle}}

-

Open in telesrv

+

Open in {{.AppName}}

{{.Description}}

Old test clients only: open with tg://

{{.CanonicalURL}}

diff --git a/internal/web/stickerlinks/server_test.go b/internal/web/server_test.go similarity index 78% rename from internal/web/stickerlinks/server_test.go rename to internal/web/server_test.go index 1d3d6720..055fca32 100644 --- a/internal/web/stickerlinks/server_test.go +++ b/internal/web/server_test.go @@ -1,4 +1,4 @@ -package stickerlinks +package web import ( "context" @@ -11,6 +11,39 @@ import ( "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) { resolver := fakeResolver{ "fresh_pack": { @@ -24,7 +57,7 @@ func TestHandlerServesStickerSetLandingPage(t *testing.T) { rr := httptest.NewRecorder() 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 { 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() 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 { 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() 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 { 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() 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 { 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&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) { users := fakeUsers{ "alice": { @@ -183,7 +284,7 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { photos := &fakePhotos{byID: map[int64]domain.Photo{ 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 { path string @@ -233,7 +334,7 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(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}, }, nil, nil, nil, "https://telesrv.net") rr := httptest.NewRecorder() @@ -283,7 +384,7 @@ func TestHandlerHonorsAnonymousAboutAndPhotoPrivacy(t *testing.T) { domain.PrivacyKeyAbout: false, domain.PrivacyKeyProfilePhoto: false, } - handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{ "alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"}, }, nil, privacy, photos, "https://telesrv.net") rr := httptest.NewRecorder() @@ -314,7 +415,7 @@ func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) { "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"}, }, nil, nil, photos, "https://telesrv.net") @@ -345,7 +446,7 @@ func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) { } func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) { - handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{ "sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"}, }, fakeChannels{ "sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true}, @@ -358,7 +459,7 @@ func TestHandlerFailsFastForAmbiguousUsernameOwner(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"} { rr := httptest.NewRecorder() 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}, } - handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{ "alice": {ID: userID, Username: "Alice", FirstName: "Alice"}, }, nil, nil, photos, "https://telesrv.net") rr := httptest.NewRecorder() @@ -419,7 +520,7 @@ func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) { rr := httptest.NewRecorder() 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 { 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) { - handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, fakeUsers{ "alice": { ID: 2001, Username: "Alice", @@ -460,7 +561,7 @@ func TestHandlerLookupErrorIsInternalServerError(t *testing.T) { rr := httptest.NewRecorder() 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 { t.Fatalf("status = %d, want 500", rr.Code)